package service import ( "baron-sso-backend/internal/domain" "context" "errors" "testing" "time" "github.com/stretchr/testify/require" ) type fakeRPUsageOutboxRepo struct { created []domain.RPUsageEvent ready []domain.RPUsageEvent processing []string processed []string failed []string createErr error projectErr error } func (f *fakeRPUsageOutboxRepo) Create(ctx context.Context, event *domain.RPUsageEvent) error { if f.createErr != nil { return f.createErr } f.created = append(f.created, *event) return nil } func (f *fakeRPUsageOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) { return f.ready, nil } func (f *fakeRPUsageOutboxRepo) MarkProcessing(ctx context.Context, id string) error { f.processing = append(f.processing, id) return nil } func (f *fakeRPUsageOutboxRepo) MarkProcessed(ctx context.Context, id string) error { f.processed = append(f.processed, id) return nil } func (f *fakeRPUsageOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error { f.failed = append(f.failed, id) return nil } type fakeRPUsageProjectionRepo struct { created []domain.RPUsageEvent err error } func (f *fakeRPUsageProjectionRepo) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error { if f.err != nil { return f.err } f.created = append(f.created, event) return nil } func TestRPUsageEventEmitterRequiresCanonicalFields(t *testing.T) { repo := &fakeRPUsageOutboxRepo{} emitter := NewRPUsageEventEmitter(repo) err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{ EventType: domain.RPUsageEventTypeAuthorizationGranted, ClientID: "client-app", }) require.Error(t, err) require.Empty(t, repo.created) } func TestRPUsageEventEmitterCreatesPendingOutboxEvent(t *testing.T) { repo := &fakeRPUsageOutboxRepo{} emitter := NewRPUsageEventEmitter(repo) err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{ EventType: domain.RPUsageEventTypeAuthorizationGranted, Subject: "user-123", ClientID: "client-app", Source: "hydra_consent", CorrelationID: "challenge-1", }) require.NoError(t, err) require.Len(t, repo.created, 1) require.NotEmpty(t, repo.created[0].DedupeKey) require.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, repo.created[0].EventType) require.Equal(t, "hydra_consent", repo.created[0].Source) } func TestRPUsageProjectorWorkerMarksProcessedAfterProjection(t *testing.T) { outbox := &fakeRPUsageOutboxRepo{ ready: []domain.RPUsageEvent{{ ID: "event-1", EventType: domain.RPUsageEventTypeAuthorizationGranted, Subject: "user-123", ClientID: "client-app", }}, } projection := &fakeRPUsageProjectionRepo{} worker := NewRPUsageProjectorWorker(outbox, projection) worker.processOnce(context.Background()) require.Equal(t, []string{"event-1"}, outbox.processing) require.Equal(t, []string{"event-1"}, outbox.processed) require.Empty(t, outbox.failed) require.Len(t, projection.created, 1) } func TestRPUsageProjectorWorkerMarksFailedWhenProjectionFails(t *testing.T) { outbox := &fakeRPUsageOutboxRepo{ ready: []domain.RPUsageEvent{{ ID: "event-1", EventType: domain.RPUsageEventTypeAuthorizationGranted, Subject: "user-123", ClientID: "client-app", }}, } projection := &fakeRPUsageProjectionRepo{err: errors.New("clickhouse unavailable")} worker := NewRPUsageProjectorWorker(outbox, projection) worker.processOnce(context.Background()) require.Equal(t, []string{"event-1"}, outbox.processing) require.Empty(t, outbox.processed) require.Equal(t, []string{"event-1"}, outbox.failed) }