package notificationstore import ( "context" "errors" "testing" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/service/acceptintent" "galaxy/notification/internal/service/malformedintent" "galaxy/notification/internal/service/routestate" ) func TestPing(t *testing.T) { store := newTestStore(t) if err := store.Ping(context.Background()); err != nil { t.Fatalf("ping: %v", err) } } func TestCreateAcceptanceAndReads(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) pushRoute := newPendingRoute(notification.NotificationID, "push:user-1", intentstream.ChannelPush, "user-1", now) emailRoute := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now) idem := newIdempotency(notification, now) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{pushRoute, emailRoute}, Idempotency: idem, }); err != nil { t.Fatalf("create acceptance: %v", err) } gotNotification, found, err := store.GetNotification(ctx, notification.NotificationID) if err != nil || !found { t.Fatalf("get notification: found=%v err=%v", found, err) } if gotNotification.PayloadJSON != notification.PayloadJSON { t.Fatalf("notification payload mismatch: got %q want %q", gotNotification.PayloadJSON, notification.PayloadJSON) } if len(gotNotification.RecipientUserIDs) != 1 || gotNotification.RecipientUserIDs[0] != "user-1" { t.Fatalf("recipient_user_ids round-trip: %#v", gotNotification.RecipientUserIDs) } gotIdem, found, err := store.GetIdempotency(ctx, notification.Producer, notification.IdempotencyKey) if err != nil || !found { t.Fatalf("get idempotency: found=%v err=%v", found, err) } if gotIdem.NotificationID != notification.NotificationID { t.Fatalf("idempotency notification id mismatch: got %q want %q", gotIdem.NotificationID, notification.NotificationID) } if !gotIdem.ExpiresAt.Equal(idem.ExpiresAt) { t.Fatalf("idempotency expires_at mismatch: got %v want %v", gotIdem.ExpiresAt, idem.ExpiresAt) } gotRoute, found, err := store.GetRoute(ctx, notification.NotificationID, pushRoute.RouteID) if err != nil || !found { t.Fatalf("get push route: found=%v err=%v", found, err) } if gotRoute.Channel != intentstream.ChannelPush { t.Fatalf("push route channel mismatch: got %q", gotRoute.Channel) } if !gotRoute.NextAttemptAt.Equal(pushRoute.NextAttemptAt) { t.Fatalf("push route next_attempt_at mismatch: got %v want %v", gotRoute.NextAttemptAt, pushRoute.NextAttemptAt) } } func TestCreateAcceptanceIdempotencyConflict(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) route := newPendingRoute(notification.NotificationID, "push:user-1", intentstream.ChannelPush, "user-1", now) first := acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, now), } if err := store.CreateAcceptance(ctx, first); err != nil { t.Fatalf("first acceptance: %v", err) } clone := notification clone.NotificationID = "n-2" cloneRoute := route cloneRoute.NotificationID = clone.NotificationID clone.AcceptedAt = now.Add(time.Second) clone.UpdatedAt = clone.AcceptedAt cloneIdem := newIdempotency(clone, now.Add(time.Second)) cloneIdem.IdempotencyKey = notification.IdempotencyKey err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: clone, Routes: []acceptintent.NotificationRoute{cloneRoute}, Idempotency: cloneIdem, }) if !errors.Is(err, acceptintent.ErrConflict) { t.Fatalf("expected acceptintent.ErrConflict, got %v", err) } } func TestListDueRoutes(t *testing.T) { store := newTestStore(t) ctx := context.Background() base := time.Now().UTC().Truncate(time.Millisecond) pastNotification := newNotification(t, "past", base) pastRoute := newPendingRoute(pastNotification.NotificationID, "push:past", intentstream.ChannelPush, "user-1", base.Add(-time.Minute)) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: pastNotification, Routes: []acceptintent.NotificationRoute{pastRoute}, Idempotency: newIdempotency(pastNotification, base), }); err != nil { t.Fatalf("acceptance past: %v", err) } futureNotification := newNotification(t, "future", base) futureNotification.IdempotencyKey = "key-future" futureRoute := newPendingRoute(futureNotification.NotificationID, "push:future", intentstream.ChannelPush, "user-2", base.Add(time.Hour)) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: futureNotification, Routes: []acceptintent.NotificationRoute{futureRoute}, Idempotency: newIdempotency(futureNotification, base), }); err != nil { t.Fatalf("acceptance future: %v", err) } due, err := store.ListDueRoutes(ctx, base, 10) if err != nil { t.Fatalf("list due routes: %v", err) } if len(due) != 1 { t.Fatalf("expected one due route, got %d", len(due)) } if due[0].NotificationID != "past" || due[0].RouteID != "push:past" { t.Fatalf("unexpected due route: %#v", due[0]) } } func TestCompleteRoutePublishedHappyPath(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, now), }); err != nil { t.Fatalf("acceptance: %v", err) } publishedAt := now.Add(time.Second) err := store.CompleteRoutePublished(ctx, routestate.CompleteRoutePublishedInput{ ExpectedRoute: route, LeaseToken: "token", PublishedAt: publishedAt, Stream: "mail:delivery_commands", StreamValues: map[string]any{"k": "v"}, }) if err != nil { t.Fatalf("complete published: %v", err) } got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID) if err != nil { t.Fatalf("get route: %v", err) } if got.Status != acceptintent.RouteStatusPublished { t.Fatalf("expected status published, got %q", got.Status) } if got.AttemptCount != 1 { t.Fatalf("expected attempt_count 1, got %d", got.AttemptCount) } if !got.NextAttemptAt.IsZero() { t.Fatalf("expected next_attempt_at cleared, got %v", got.NextAttemptAt) } if !got.PublishedAt.Equal(publishedAt) { t.Fatalf("expected published_at %v, got %v", publishedAt, got.PublishedAt) } } func TestCompleteRoutePublishedConflictOnUpdatedAtMismatch(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, now), }); err != nil { t.Fatalf("acceptance: %v", err) } stale := route stale.UpdatedAt = now.Add(-time.Minute) // mismatch on purpose err := store.CompleteRoutePublished(ctx, routestate.CompleteRoutePublishedInput{ ExpectedRoute: stale, LeaseToken: "token", PublishedAt: now.Add(time.Second), Stream: "mail:delivery_commands", StreamValues: map[string]any{"k": "v"}, }) if !errors.Is(err, routestate.ErrConflict) { t.Fatalf("expected routestate.ErrConflict, got %v", err) } } func TestCompleteRouteFailedReschedule(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, now), }); err != nil { t.Fatalf("acceptance: %v", err) } failedAt := now.Add(time.Second) nextAttemptAt := now.Add(2 * time.Minute) err := store.CompleteRouteFailed(ctx, routestate.CompleteRouteFailedInput{ ExpectedRoute: route, LeaseToken: "token", FailedAt: failedAt, NextAttemptAt: nextAttemptAt, FailureClassification: "smtp_temporary_failure", FailureMessage: "graylisted", }) if err != nil { t.Fatalf("complete failed: %v", err) } got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID) if err != nil { t.Fatalf("get route: %v", err) } if got.Status != acceptintent.RouteStatusFailed { t.Fatalf("expected status failed, got %q", got.Status) } if got.AttemptCount != 1 { t.Fatalf("expected attempt_count 1, got %d", got.AttemptCount) } if !got.NextAttemptAt.Equal(nextAttemptAt) { t.Fatalf("expected next_attempt_at %v, got %v", nextAttemptAt, got.NextAttemptAt) } if got.LastErrorClassification != "smtp_temporary_failure" { t.Fatalf("expected error classification, got %q", got.LastErrorClassification) } } func TestCompleteRouteDeadLetter(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) notification := newNotification(t, "n-1", now) route := newPendingRoute(notification.NotificationID, "email:user-1", intentstream.ChannelEmail, "user-1", now) route.MaxAttempts = 1 // single attempt budget so the first failure is terminal. if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, now), }); err != nil { t.Fatalf("acceptance: %v", err) } deadAt := now.Add(time.Second) err := store.CompleteRouteDeadLetter(ctx, routestate.CompleteRouteDeadLetterInput{ ExpectedRoute: route, LeaseToken: "token", DeadLetteredAt: deadAt, FailureClassification: "smtp_permanent_failure", FailureMessage: "rejected", RecoveryHint: "manual review", }) if err != nil { t.Fatalf("complete dead letter: %v", err) } got, _, err := store.GetRoute(ctx, route.NotificationID, route.RouteID) if err != nil { t.Fatalf("get route: %v", err) } if got.Status != acceptintent.RouteStatusDeadLetter { t.Fatalf("expected status dead_letter, got %q", got.Status) } if !got.DeadLetteredAt.Equal(deadAt) { t.Fatalf("expected dead_lettered_at %v, got %v", deadAt, got.DeadLetteredAt) } // Check that the dead_letters audit row was inserted. row := store.db.QueryRow(`SELECT failure_classification, recovery_hint FROM dead_letters WHERE notification_id = $1 AND route_id = $2`, route.NotificationID, route.RouteID) var classification string var hint string if err := row.Scan(&classification, &hint); err != nil { t.Fatalf("scan dead_letter row: %v", err) } if classification != "smtp_permanent_failure" || hint != "manual review" { t.Fatalf("dead_letter row mismatch: classification=%q hint=%q", classification, hint) } } func TestReadRouteScheduleSnapshot(t *testing.T) { store := newTestStore(t) ctx := context.Background() base := time.Now().UTC().Truncate(time.Millisecond) for index, offset := range []time.Duration{-time.Hour, time.Minute, 2 * time.Minute} { notification := newNotification(t, idString("n-", index), base) notification.IdempotencyKey = idString("key-", index) route := newPendingRoute(notification.NotificationID, idString("push:user-", index), intentstream.ChannelPush, idString("user-", index), base.Add(offset)) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: notification, Routes: []acceptintent.NotificationRoute{route}, Idempotency: newIdempotency(notification, base), }); err != nil { t.Fatalf("acceptance %d: %v", index, err) } } snap, err := store.ReadRouteScheduleSnapshot(ctx) if err != nil { t.Fatalf("read snapshot: %v", err) } if snap.Depth != 3 { t.Fatalf("expected depth 3, got %d", snap.Depth) } if snap.OldestScheduledFor == nil { t.Fatalf("expected oldest scheduled time, got nil") } if !snap.OldestScheduledFor.Equal(base.Add(-time.Hour)) { t.Fatalf("expected oldest %v, got %v", base.Add(-time.Hour), *snap.OldestScheduledFor) } } func TestMalformedIntentRecordAndGet(t *testing.T) { store := newTestStore(t) ctx := context.Background() now := time.Now().UTC().Truncate(time.Millisecond) entry := malformedintent.Entry{ StreamEntryID: "stream-1", NotificationType: "game.turn.ready", Producer: "game-master", IdempotencyKey: "key-1", FailureCode: malformedintent.FailureCodeInvalidPayload, FailureMessage: "decode failed", RawFields: map[string]any{"raw_payload": "abc"}, RecordedAt: now, } if err := store.Record(ctx, entry); err != nil { t.Fatalf("record malformed: %v", err) } // idempotent re-record if err := store.Record(ctx, entry); err != nil { t.Fatalf("record malformed twice: %v", err) } got, found, err := store.GetMalformedIntent(ctx, entry.StreamEntryID) if err != nil || !found { t.Fatalf("get malformed: found=%v err=%v", found, err) } if got.FailureCode != malformedintent.FailureCodeInvalidPayload { t.Fatalf("failure_code mismatch: %q", got.FailureCode) } if got.FailureMessage != entry.FailureMessage { t.Fatalf("failure_message mismatch: %q", got.FailureMessage) } } func TestRetentionDeletesAndCascade(t *testing.T) { store := newTestStore(t) ctx := context.Background() old := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Millisecond) fresh := time.Now().UTC().Truncate(time.Millisecond) oldNotification := newNotification(t, "old", old) oldNotification.IdempotencyKey = "key-old" oldRoute := newPendingRoute(oldNotification.NotificationID, "push:user-old", intentstream.ChannelPush, "user-old", old) oldRoute.MaxAttempts = 1 if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: oldNotification, Routes: []acceptintent.NotificationRoute{oldRoute}, Idempotency: newIdempotency(oldNotification, old), }); err != nil { t.Fatalf("acceptance old: %v", err) } if err := store.CompleteRouteDeadLetter(ctx, routestate.CompleteRouteDeadLetterInput{ ExpectedRoute: oldRoute, LeaseToken: "token", DeadLetteredAt: old.Add(time.Second), FailureClassification: "smtp_permanent_failure", FailureMessage: "rejected", }); err != nil { t.Fatalf("dead letter old: %v", err) } freshNotification := newNotification(t, "fresh", fresh) freshNotification.IdempotencyKey = "key-fresh" freshRoute := newPendingRoute(freshNotification.NotificationID, "push:user-fresh", intentstream.ChannelPush, "user-fresh", fresh) if err := store.CreateAcceptance(ctx, acceptintent.CreateAcceptanceInput{ Notification: freshNotification, Routes: []acceptintent.NotificationRoute{freshRoute}, Idempotency: newIdempotency(freshNotification, fresh), }); err != nil { t.Fatalf("acceptance fresh: %v", err) } cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) deleted, err := store.DeleteRecordsOlderThan(ctx, cutoff) if err != nil { t.Fatalf("delete records: %v", err) } if deleted != 1 { t.Fatalf("expected 1 deleted, got %d", deleted) } if _, found, err := store.GetNotification(ctx, "old"); err != nil || found { t.Fatalf("old notification should be gone: found=%v err=%v", found, err) } // Confirm cascade emptied routes/dead_letters for old notification. var routeCount int if err := store.db.QueryRow(`SELECT COUNT(*) FROM routes WHERE notification_id = 'old'`).Scan(&routeCount); err != nil { t.Fatalf("count routes: %v", err) } if routeCount != 0 { t.Fatalf("expected 0 cascaded routes, got %d", routeCount) } var deadCount int if err := store.db.QueryRow(`SELECT COUNT(*) FROM dead_letters WHERE notification_id = 'old'`).Scan(&deadCount); err != nil { t.Fatalf("count dead letters: %v", err) } if deadCount != 0 { t.Fatalf("expected 0 cascaded dead letters, got %d", deadCount) } // Fresh notification stays. if _, found, err := store.GetNotification(ctx, "fresh"); err != nil || !found { t.Fatalf("fresh notification missing: found=%v err=%v", found, err) } } func TestDeleteMalformedIntentsOlderThan(t *testing.T) { store := newTestStore(t) ctx := context.Background() old := time.Now().UTC().Add(-30 * 24 * time.Hour).Truncate(time.Millisecond) fresh := time.Now().UTC().Truncate(time.Millisecond) oldEntry := malformedintent.Entry{ StreamEntryID: "stream-old", FailureCode: malformedintent.FailureCodeInvalidPayload, FailureMessage: "decode failed", RawFields: map[string]any{}, RecordedAt: old, } if err := store.Record(ctx, oldEntry); err != nil { t.Fatalf("record old: %v", err) } freshEntry := malformedintent.Entry{ StreamEntryID: "stream-fresh", FailureCode: malformedintent.FailureCodeInvalidPayload, FailureMessage: "decode failed", RawFields: map[string]any{}, RecordedAt: fresh, } if err := store.Record(ctx, freshEntry); err != nil { t.Fatalf("record fresh: %v", err) } cutoff := time.Now().UTC().Add(-7 * 24 * time.Hour) deleted, err := store.DeleteMalformedIntentsOlderThan(ctx, cutoff) if err != nil { t.Fatalf("delete: %v", err) } if deleted != 1 { t.Fatalf("expected 1 deleted, got %d", deleted) } if _, found, err := store.GetMalformedIntent(ctx, "stream-old"); err != nil || found { t.Fatalf("old malformed intent should be gone: found=%v err=%v", found, err) } if _, found, err := store.GetMalformedIntent(ctx, "stream-fresh"); err != nil || !found { t.Fatalf("fresh malformed intent missing: found=%v err=%v", found, err) } } // ---- helpers ---- func newNotification(t testing.TB, id string, occurred time.Time) acceptintent.NotificationRecord { t.Helper() return acceptintent.NotificationRecord{ NotificationID: id, NotificationType: intentstream.NotificationTypeGameTurnReady, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"a":1}`, IdempotencyKey: "key-" + id, RequestFingerprint: "fp-" + id, OccurredAt: occurred, AcceptedAt: occurred, UpdatedAt: occurred, } } func newIdempotency(record acceptintent.NotificationRecord, createdAt time.Time) acceptintent.IdempotencyRecord { return acceptintent.IdempotencyRecord{ Producer: record.Producer, IdempotencyKey: record.IdempotencyKey, NotificationID: record.NotificationID, RequestFingerprint: record.RequestFingerprint, CreatedAt: createdAt, ExpiresAt: createdAt.Add(7 * 24 * time.Hour), } } func newPendingRoute(notificationID string, routeID string, channel intentstream.Channel, recipient string, dueAt time.Time) acceptintent.NotificationRoute { return acceptintent.NotificationRoute{ NotificationID: notificationID, RouteID: routeID, Channel: channel, RecipientRef: "user:" + recipient, Status: acceptintent.RouteStatusPending, AttemptCount: 0, MaxAttempts: 3, NextAttemptAt: dueAt, ResolvedEmail: recipient + "@example.com", ResolvedLocale: "en", CreatedAt: dueAt, UpdatedAt: dueAt, } } func idString(prefix string, index int) string { switch index { case 0: return prefix + "0" case 1: return prefix + "1" case 2: return prefix + "2" default: return prefix + "n" } }