package acceptintent import ( "context" "errors" "testing" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/config" "github.com/stretchr/testify/require" ) func TestServiceAcceptsIntentAndMaterializesUserRoutes(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(map[string]UserRecord{ "user-1": {Email: "one@example.com", PreferredLanguage: "en"}, "user-2": {Email: "two@example.com", PreferredLanguage: "en-US"}, }) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) result, err := service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-2", "user-1"}, "request-123", "trace-123", time.UnixMilli(1775121700001).UTC()), }) require.NoError(t, err) require.Equal(t, OutcomeAccepted, result.Outcome) require.Len(t, store.createInputs, 1) createInput := store.createInputs[0] require.Equal(t, "1775121700000-0", createInput.Notification.NotificationID) require.Equal(t, []string{"user-1", "user-2"}, createInput.Notification.RecipientUserIDs) require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, createInput.Notification.PayloadJSON) require.Len(t, createInput.Routes, 4) pushUser1 := routeByID(t, createInput.Routes, "push:user:user-1") emailUser1 := routeByID(t, createInput.Routes, "email:user:user-1") pushUser2 := routeByID(t, createInput.Routes, "push:user:user-2") emailUser2 := routeByID(t, createInput.Routes, "email:user:user-2") require.Equal(t, RouteStatusPending, pushUser1.Status) require.Equal(t, 3, pushUser1.MaxAttempts) require.Equal(t, "one@example.com", pushUser1.ResolvedEmail) require.Equal(t, "en", pushUser1.ResolvedLocale) require.Equal(t, RouteStatusPending, emailUser1.Status) require.Equal(t, 7, emailUser1.MaxAttempts) require.Equal(t, "one@example.com", emailUser1.ResolvedEmail) require.Equal(t, "en", emailUser1.ResolvedLocale) require.Equal(t, "two@example.com", pushUser2.ResolvedEmail) require.Equal(t, "en", pushUser2.ResolvedLocale) require.Equal(t, "two@example.com", emailUser2.ResolvedEmail) require.Equal(t, "en", emailUser2.ResolvedLocale) require.Equal(t, []string{"user-1", "user-2"}, directory.lookups) } func TestServiceTreatsEquivalentReplayAsDuplicate(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(map[string]UserRecord{ "user-1": {Email: "one@example.com", PreferredLanguage: "en"}, }) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) firstInput := AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-1", "trace-1", time.UnixMilli(1775121700001).UTC()), } secondInput := AcceptInput{ NotificationID: "1775121700001-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-2", "trace-2", time.UnixMilli(1775121799999).UTC()), } firstResult, err := service.Execute(context.Background(), firstInput) require.NoError(t, err) require.Equal(t, OutcomeAccepted, firstResult.Outcome) secondResult, err := service.Execute(context.Background(), secondInput) require.NoError(t, err) require.Equal(t, OutcomeDuplicate, secondResult.Outcome) require.Len(t, store.createInputs, 1) require.Equal(t, []string{"user-1"}, directory.lookups) } func TestServiceRejectsConflictOnSameIdempotencyScope(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(map[string]UserRecord{ "user-1": {Email: "one@example.com", PreferredLanguage: "en"}, }) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) _, err = service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()), }) require.NoError(t, err) _, err = service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700002-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":55}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700002).UTC()), }) require.ErrorIs(t, err, ErrConflict) } func TestServiceMaterializesPublicLobbyApplicationAdminRoutes(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(nil) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, AdminRouting: config.AdminRoutingConfig{ LobbyApplicationSubmitted: []string{"owner@example.com"}, }, }) require.NoError(t, err) result, err := service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validPublicApplicationIntent(), }) require.NoError(t, err) require.Equal(t, OutcomeAccepted, result.Outcome) require.Len(t, store.createInputs, 1) require.Len(t, store.createInputs[0].Routes, 2) pushRoute := routeByID(t, store.createInputs[0].Routes, "push:email:owner@example.com") emailRoute := routeByID(t, store.createInputs[0].Routes, "email:email:owner@example.com") require.Equal(t, RouteStatusSkipped, pushRoute.Status) require.Equal(t, "owner@example.com", pushRoute.ResolvedEmail) require.Equal(t, "en", pushRoute.ResolvedLocale) require.Equal(t, RouteStatusPending, emailRoute.Status) require.Equal(t, "owner@example.com", emailRoute.ResolvedEmail) require.Equal(t, "en", emailRoute.ResolvedLocale) require.Empty(t, directory.lookups) } func TestServiceMaterializesSyntheticAdminConfigRouteWhenListIsEmpty(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(nil) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) result, err := service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validPublicApplicationIntent(), }) require.NoError(t, err) require.Equal(t, OutcomeAccepted, result.Outcome) require.Len(t, store.createInputs, 1) require.Len(t, store.createInputs[0].Routes, 1) route := store.createInputs[0].Routes[0] require.Equal(t, "email:config:lobby.application.submitted", route.RouteID) require.Equal(t, RouteStatusSkipped, route.Status) require.Equal(t, 7, route.MaxAttempts) require.True(t, route.NextAttemptAt.IsZero()) require.Empty(t, directory.lookups) } func TestServiceMaterializesChannelMatrixAndRetryBudgets(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() tests := []struct { name string intent intentstream.Intent adminRouting config.AdminRoutingConfig wantRoutes map[string]struct { status RouteStatus maxAttempts int } }{ { name: "user push and email", intent: validTurnReadyIntent( `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", now, ), wantRoutes: map[string]struct { status RouteStatus maxAttempts int }{ "push:user:user-1": {status: RouteStatusPending, maxAttempts: 3}, "email:user:user-1": {status: RouteStatusPending, maxAttempts: 7}, }, }, { name: "user email only", intent: intentstream.Intent{ NotificationType: intentstream.NotificationTypeLobbyInviteExpired, Producer: intentstream.ProducerGameLobby, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, IdempotencyKey: "game-123:invite-expired", OccurredAt: now, PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`, }, wantRoutes: map[string]struct { status RouteStatus maxAttempts int }{ "push:user:user-1": {status: RouteStatusSkipped, maxAttempts: 3}, "email:user:user-1": {status: RouteStatusPending, maxAttempts: 7}, }, }, { name: "admin email only", intent: intentstream.Intent{ NotificationType: intentstream.NotificationTypeGeoReviewRecommended, Producer: intentstream.ProducerGeoProfile, AudienceKind: intentstream.AudienceKindAdminEmail, IdempotencyKey: "geo:user-1", OccurredAt: now, PayloadJSON: `{"observed_country":"DE","review_reason":"country_mismatch","usual_connection_country":"PL","user_email":"pilot@example.com","user_id":"user-1"}`, }, adminRouting: config.AdminRoutingConfig{ GeoReviewRecommended: []string{"admin@example.com"}, }, wantRoutes: map[string]struct { status RouteStatus maxAttempts int }{ "push:email:admin@example.com": {status: RouteStatusSkipped, maxAttempts: 3}, "email:email:admin@example.com": {status: RouteStatusPending, maxAttempts: 7}, }, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(map[string]UserRecord{ "user-1": {Email: "pilot@example.com", PreferredLanguage: "fr-FR"}, }) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: now}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, AdminRouting: tt.adminRouting, }) require.NoError(t, err) result, err := service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: tt.intent, }) require.NoError(t, err) require.Equal(t, OutcomeAccepted, result.Outcome) require.Len(t, store.createInputs, 1) require.Len(t, store.createInputs[0].Routes, len(tt.wantRoutes)) for routeID, want := range tt.wantRoutes { route := routeByID(t, store.createInputs[0].Routes, routeID) require.Equal(t, want.status, route.Status) require.Equal(t, want.maxAttempts, route.MaxAttempts) } }) } } func TestServiceReturnsRecipientNotFoundForMissingUser(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(nil) service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) _, err = service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-missing"}, "", "", time.UnixMilli(1775121700001).UTC()), }) require.ErrorIs(t, err, ErrRecipientNotFound) require.Empty(t, store.createInputs) require.Equal(t, []string{"user-missing"}, directory.lookups) } func TestServiceReturnsServiceUnavailableWhenDirectoryFails(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(nil) directory.errByUserID["user-1"] = errors.New("user service unavailable") service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) _, err = service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()), }) require.ErrorIs(t, err, ErrServiceUnavailable) require.Empty(t, store.createInputs) } func TestServiceRecordsIntentAndUserEnrichmentTelemetry(t *testing.T) { t.Parallel() store := newRecordingStore() directory := newStaticUserDirectory(map[string]UserRecord{ "user-1": {Email: "one@example.com", PreferredLanguage: "en"}, }) telemetry := &recordingTelemetry{} service, err := New(Config{ Store: store, UserDirectory: directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, Telemetry: telemetry, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) input := AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()), } result, err := service.Execute(context.Background(), input) require.NoError(t, err) require.Equal(t, OutcomeAccepted, result.Outcome) duplicateInput := input duplicateInput.NotificationID = "1775121700001-0" result, err = service.Execute(context.Background(), duplicateInput) require.NoError(t, err) require.Equal(t, OutcomeDuplicate, result.Outcome) require.Equal(t, []intentOutcomeRecord{ { notificationType: "game.turn.ready", producer: "game_master", audienceKind: "user", outcome: "accepted", }, { notificationType: "game.turn.ready", producer: "game_master", audienceKind: "user", outcome: "duplicate", }, }, telemetry.intentOutcomes) require.Equal(t, []userEnrichmentRecord{ {notificationType: "game.turn.ready", result: "success"}, }, telemetry.userEnrichment) } func TestServiceRecordsUserEnrichmentFailureTelemetry(t *testing.T) { t.Parallel() tests := []struct { name string directory *staticUserDirectory want string }{ { name: "recipient not found", directory: newStaticUserDirectory(nil), want: "recipient_not_found", }, { name: "service unavailable", directory: func() *staticUserDirectory { directory := newStaticUserDirectory(nil) directory.errByUserID["user-1"] = errors.New("user service unavailable") return directory }(), want: "service_unavailable", }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() telemetry := &recordingTelemetry{} service, err := New(Config{ Store: newRecordingStore(), UserDirectory: tt.directory, Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()}, Telemetry: telemetry, PushMaxAttempts: 3, EmailMaxAttempts: 7, IdempotencyTTL: 7 * 24 * time.Hour, }) require.NoError(t, err) _, err = service.Execute(context.Background(), AcceptInput{ NotificationID: "1775121700000-0", Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()), }) require.Error(t, err) require.Equal(t, []userEnrichmentRecord{ {notificationType: "game.turn.ready", result: tt.want}, }, telemetry.userEnrichment) }) } } type recordingStore struct { createInputs []CreateAcceptanceInput idempotency map[string]IdempotencyRecord notifications map[string]NotificationRecord } func newRecordingStore() *recordingStore { return &recordingStore{ idempotency: make(map[string]IdempotencyRecord), notifications: make(map[string]NotificationRecord), } } func (store *recordingStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error { if err := input.Validate(); err != nil { return err } key := string(input.Idempotency.Producer) + ":" + input.Idempotency.IdempotencyKey if _, ok := store.idempotency[key]; ok { return ErrConflict } store.createInputs = append(store.createInputs, input) store.idempotency[key] = input.Idempotency store.notifications[input.Notification.NotificationID] = input.Notification return nil } func (store *recordingStore) GetIdempotency(_ context.Context, producer intentstream.Producer, idempotencyKey string) (IdempotencyRecord, bool, error) { record, ok := store.idempotency[string(producer)+":"+idempotencyKey] return record, ok, nil } func (store *recordingStore) GetNotification(_ context.Context, notificationID string) (NotificationRecord, bool, error) { record, ok := store.notifications[notificationID] return record, ok, nil } type fixedClock struct { now time.Time } func (clock fixedClock) Now() time.Time { return clock.now } func validTurnReadyIntent(payload string, recipients []string, requestID string, traceID string, occurredAt time.Time) intentstream.Intent { sorted := append([]string(nil), recipients...) if len(sorted) == 2 && sorted[0] == "user-2" { sorted[0], sorted[1] = sorted[1], sorted[0] } return intentstream.Intent{ NotificationType: intentstream.NotificationTypeGameTurnReady, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: sorted, IdempotencyKey: "game-123:turn-54", OccurredAt: occurredAt.UTC().Truncate(time.Millisecond), RequestID: requestID, TraceID: traceID, PayloadJSON: payload, } } func validPublicApplicationIntent() intentstream.Intent { return intentstream.Intent{ NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted, Producer: intentstream.ProducerGameLobby, AudienceKind: intentstream.AudienceKindAdminEmail, IdempotencyKey: "game-456:application-submitted:user-42", OccurredAt: time.UnixMilli(1775121700002).UTC(), PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`, } } func routeByID(t *testing.T, routes []NotificationRoute, routeID string) NotificationRoute { t.Helper() for _, route := range routes { if route.RouteID == routeID { return route } } t.Fatalf("route %q not found", routeID) return NotificationRoute{} } type staticUserDirectory struct { records map[string]UserRecord errByUserID map[string]error lookups []string } func newStaticUserDirectory(records map[string]UserRecord) *staticUserDirectory { return &staticUserDirectory{ records: records, errByUserID: make(map[string]error), } } func (directory *staticUserDirectory) GetUserByID(_ context.Context, userID string) (UserRecord, error) { directory.lookups = append(directory.lookups, userID) if err, ok := directory.errByUserID[userID]; ok { return UserRecord{}, err } record, ok := directory.records[userID] if !ok { return UserRecord{}, ErrRecipientNotFound } return record, nil } type recordingTelemetry struct { intentOutcomes []intentOutcomeRecord userEnrichment []userEnrichmentRecord } func (telemetry *recordingTelemetry) RecordIntentOutcome(_ context.Context, notificationType string, producer string, audienceKind string, outcome string) { telemetry.intentOutcomes = append(telemetry.intentOutcomes, intentOutcomeRecord{ notificationType: notificationType, producer: producer, audienceKind: audienceKind, outcome: outcome, }) } func (telemetry *recordingTelemetry) RecordUserEnrichmentAttempt(_ context.Context, notificationType string, result string) { telemetry.userEnrichment = append(telemetry.userEnrichment, userEnrichmentRecord{ notificationType: notificationType, result: result, }) } type intentOutcomeRecord struct { notificationType string producer string audienceKind string outcome string } type userEnrichmentRecord struct { notificationType string result string }