568 lines
20 KiB
Go
568 lines
20 KiB
Go
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"
|
|
}
|
|
}
|