151 lines
4.7 KiB
Go
151 lines
4.7 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/dbiz/cdp/ingestion/ingest/internal/apperr"
|
|
"github.com/dbiz/cdp/ingestion/ingest/internal/model"
|
|
"github.com/dbiz/cdp/ingestion/ingest/internal/ratelimit"
|
|
)
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stubs -- enough surface to drive the IngestService without spinning Kafka
|
|
// or Redis. We exercise the pipeline branches: late event, dedup hit, schema
|
|
// conflict, happy path.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
type fakeLimiter struct{ allow bool }
|
|
|
|
func (f *fakeLimiter) Allow(_ context.Context, _ string, _ int, _ time.Duration) (ratelimit.Decision, error) {
|
|
if f.allow {
|
|
return ratelimit.Decision{Allowed: true, Remaining: 99}, nil
|
|
}
|
|
return ratelimit.Decision{Allowed: false, RetryAfterMS: 500}, nil
|
|
}
|
|
|
|
type fakeDedup struct{ fresh bool }
|
|
|
|
func (f *fakeDedup) CheckAndSet(_ context.Context, _, _ string) (bool, error) { return f.fresh, nil }
|
|
|
|
type fakeSchema struct {
|
|
stored map[string]string
|
|
}
|
|
|
|
func (f *fakeSchema) GetType(_ context.Context, _, _, field string) (string, error) {
|
|
if t, ok := f.stored[field]; ok {
|
|
return t, nil
|
|
}
|
|
return "", nil
|
|
}
|
|
func (f *fakeSchema) UpsertField(_ context.Context, _, _, field, dt string) error {
|
|
if f.stored == nil {
|
|
f.stored = map[string]string{}
|
|
}
|
|
f.stored[field] = dt
|
|
return nil
|
|
}
|
|
|
|
// fakeProducer captures pushes so tests can assert side effects.
|
|
type fakeProducer struct {
|
|
produced []*model.IngestedEvent
|
|
dlq []string // reason values
|
|
}
|
|
|
|
func (f *fakeProducer) Produce(_ context.Context, ev *model.IngestedEvent) error {
|
|
f.produced = append(f.produced, ev)
|
|
return nil
|
|
}
|
|
func (f *fakeProducer) ProduceDLQ(_ context.Context, _, _, _, reason, _ string, _ []byte) error {
|
|
f.dlq = append(f.dlq, reason)
|
|
return nil
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
|
|
func newSvc(t *testing.T, limiter *fakeLimiter, dedupSvc *fakeDedup, sch *fakeSchema) (*IngestService, *fakeProducer) {
|
|
t.Helper()
|
|
prod := &fakeProducer{}
|
|
return &IngestService{
|
|
producer: prod,
|
|
limiter: limiter,
|
|
dedup: dedupSvc,
|
|
schema: sch,
|
|
log: zap.NewNop(),
|
|
lateAfter: 24 * time.Hour,
|
|
}, prod
|
|
}
|
|
|
|
func TestIngest_RateLimited(t *testing.T) {
|
|
svc, _ := newSvc(t, &fakeLimiter{allow: false}, &fakeDedup{fresh: true}, &fakeSchema{})
|
|
err := svc.Ingest(context.Background(), IngestContext{WorkspaceID: "ws"},
|
|
&model.RawEvent{Type: model.EventTypeTrack, MessageID: "m1"})
|
|
ae, ok := apperr.As(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 429, ae.Code)
|
|
assert.Greater(t, ae.RetryAfter, 0)
|
|
}
|
|
|
|
func TestIngest_LateEvent(t *testing.T) {
|
|
svc, _ := newSvc(t, &fakeLimiter{allow: true}, &fakeDedup{fresh: true}, &fakeSchema{})
|
|
old := time.Now().Add(-48 * time.Hour)
|
|
err := svc.Ingest(context.Background(), IngestContext{WorkspaceID: "ws"},
|
|
&model.RawEvent{Type: model.EventTypeTrack, MessageID: "m1", SentAt: &old})
|
|
ae, ok := apperr.As(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 422, ae.Code)
|
|
}
|
|
|
|
func TestIngest_DuplicateMessageSilentlyDropped(t *testing.T) {
|
|
svc, prod := newSvc(t, &fakeLimiter{allow: true}, &fakeDedup{fresh: false}, &fakeSchema{})
|
|
err := svc.Ingest(context.Background(), IngestContext{WorkspaceID: "ws"},
|
|
&model.RawEvent{Type: model.EventTypeTrack, MessageID: "m1"})
|
|
assert.NoError(t, err)
|
|
assert.Empty(t, prod.produced, "duplicate must not be produced")
|
|
}
|
|
|
|
func TestIngest_SchemaConflict(t *testing.T) {
|
|
svc, prod := newSvc(t, &fakeLimiter{allow: true}, &fakeDedup{fresh: true},
|
|
&fakeSchema{stored: map[string]string{"price": "string"}})
|
|
props, _ := json.Marshal(map[string]any{"price": 9.99})
|
|
err := svc.Ingest(context.Background(),
|
|
IngestContext{WorkspaceID: "ws"},
|
|
&model.RawEvent{
|
|
Type: model.EventTypeTrack,
|
|
MessageID: "m1",
|
|
Properties: props,
|
|
})
|
|
ae, ok := apperr.As(err)
|
|
require.True(t, ok)
|
|
assert.Equal(t, 400, ae.Code)
|
|
assert.Equal(t, "price", ae.Field)
|
|
assert.Equal(t, []string{"schema_conflict"}, prod.dlq)
|
|
assert.Empty(t, prod.produced)
|
|
}
|
|
|
|
func TestIngest_HappyPath(t *testing.T) {
|
|
svc, prod := newSvc(t, &fakeLimiter{allow: true}, &fakeDedup{fresh: true}, &fakeSchema{})
|
|
props, _ := json.Marshal(map[string]any{"plan": "pro"})
|
|
err := svc.Ingest(context.Background(),
|
|
IngestContext{WorkspaceID: "ws", SourceID: "src", IP: "1.1.1.1"},
|
|
&model.RawEvent{
|
|
Type: model.EventTypeTrack,
|
|
MessageID: "m1",
|
|
AnonymousID: "anon-1",
|
|
Event: "Signed Up",
|
|
Properties: props,
|
|
})
|
|
require.NoError(t, err)
|
|
require.Len(t, prod.produced, 1)
|
|
ev := prod.produced[0]
|
|
assert.Equal(t, "ws", ev.WorkspaceID)
|
|
assert.Equal(t, "anon-1", ev.PartitionKey())
|
|
assert.Equal(t, "pro", ev.Properties["plan"])
|
|
}
|