feat: use postgres
This commit is contained in:
@@ -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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user