feat: use postgres

This commit is contained in:
Ilia Denisov
2026-04-26 20:34:39 +02:00
committed by GitHub
parent 48b0056b49
commit fe829285a6
365 changed files with 29223 additions and 24049 deletions
@@ -0,0 +1,567 @@
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"
}
}