312 lines
11 KiB
Go
312 lines
11 KiB
Go
package redisstate
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"galaxy/notification/internal/api/intentstream"
|
|
"galaxy/notification/internal/config"
|
|
"galaxy/notification/internal/service/acceptintent"
|
|
"galaxy/notification/internal/service/malformedintent"
|
|
|
|
"github.com/alicebob/miniredis/v2"
|
|
"github.com/redis/go-redis/v9"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestAcceptanceStoreCreateAcceptancePersistsNotificationRoutesAndSchedule(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := newTestRedisClient(t, server)
|
|
|
|
store, err := NewAcceptanceStore(client, AcceptanceConfig{
|
|
RecordTTL: 24 * time.Hour,
|
|
DeadLetterTTL: 72 * time.Hour,
|
|
IdempotencyTTL: 7 * 24 * time.Hour,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
now := time.UnixMilli(1775121700000).UTC()
|
|
input := validAdminAcceptanceInput(now)
|
|
|
|
require.NoError(t, store.CreateAcceptance(context.Background(), input))
|
|
|
|
notificationRecord, found, err := store.GetNotification(context.Background(), input.Notification.NotificationID)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
require.Equal(t, input.Notification.NotificationID, notificationRecord.NotificationID)
|
|
|
|
idempotencyRecord, found, err := store.GetIdempotency(context.Background(), input.Idempotency.Producer, input.Idempotency.IdempotencyKey)
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
require.Equal(t, input.Idempotency.RequestFingerprint, idempotencyRecord.RequestFingerprint)
|
|
|
|
pushRoutePayload, err := client.Get(context.Background(), Keyspace{}.Route(input.Notification.NotificationID, "push:email:owner@example.com")).Bytes()
|
|
require.NoError(t, err)
|
|
pushRoute, err := UnmarshalRoute(pushRoutePayload)
|
|
require.NoError(t, err)
|
|
require.Equal(t, acceptintent.RouteStatusSkipped, pushRoute.Status)
|
|
|
|
emailRouteKey := Keyspace{}.Route(input.Notification.NotificationID, "email:email:owner@example.com")
|
|
emailRoutePayload, err := client.Get(context.Background(), emailRouteKey).Bytes()
|
|
require.NoError(t, err)
|
|
emailRoute, err := UnmarshalRoute(emailRoutePayload)
|
|
require.NoError(t, err)
|
|
require.Equal(t, acceptintent.RouteStatusPending, emailRoute.Status)
|
|
|
|
scheduled, err := client.ZRangeWithScores(context.Background(), Keyspace{}.RouteSchedule(), 0, -1).Result()
|
|
require.NoError(t, err)
|
|
require.Len(t, scheduled, 1)
|
|
require.Equal(t, emailRouteKey, scheduled[0].Member)
|
|
require.Equal(t, float64(now.UnixMilli()), scheduled[0].Score)
|
|
|
|
notificationTTL, err := client.PTTL(context.Background(), Keyspace{}.Notification(input.Notification.NotificationID)).Result()
|
|
require.NoError(t, err)
|
|
require.Greater(t, notificationTTL, 23*time.Hour)
|
|
require.LessOrEqual(t, notificationTTL, 24*time.Hour)
|
|
|
|
routeTTL, err := client.PTTL(context.Background(), emailRouteKey).Result()
|
|
require.NoError(t, err)
|
|
require.Greater(t, routeTTL, 23*time.Hour)
|
|
require.LessOrEqual(t, routeTTL, 24*time.Hour)
|
|
|
|
idempotencyTTL, err := client.PTTL(context.Background(), Keyspace{}.Idempotency(input.Idempotency.Producer, input.Idempotency.IdempotencyKey)).Result()
|
|
require.NoError(t, err)
|
|
require.Greater(t, idempotencyTTL, 6*24*time.Hour)
|
|
require.LessOrEqual(t, idempotencyTTL, 7*24*time.Hour)
|
|
}
|
|
|
|
func TestMalformedIntentStoreRecordPersistsEntry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := newTestRedisClient(t, server)
|
|
|
|
store, err := NewMalformedIntentStore(client, 72*time.Hour)
|
|
require.NoError(t, err)
|
|
|
|
entry := malformedintent.Entry{
|
|
StreamEntryID: "1775121700000-0",
|
|
NotificationType: "game.turn.ready",
|
|
Producer: "game_master",
|
|
IdempotencyKey: "game-123:turn-54",
|
|
FailureCode: malformedintent.FailureCodeInvalidPayload,
|
|
FailureMessage: "payload_json.turn_number is required",
|
|
RawFields: map[string]any{
|
|
"notification_type": "game.turn.ready",
|
|
},
|
|
RecordedAt: time.UnixMilli(1775121700000).UTC(),
|
|
}
|
|
|
|
require.NoError(t, store.Record(context.Background(), entry))
|
|
|
|
payload, err := client.Get(context.Background(), Keyspace{}.MalformedIntent(entry.StreamEntryID)).Bytes()
|
|
require.NoError(t, err)
|
|
recordedEntry, err := UnmarshalMalformedIntent(payload)
|
|
require.NoError(t, err)
|
|
require.Equal(t, entry.StreamEntryID, recordedEntry.StreamEntryID)
|
|
require.Equal(t, entry.FailureCode, recordedEntry.FailureCode)
|
|
|
|
ttl, err := client.PTTL(context.Background(), Keyspace{}.MalformedIntent(entry.StreamEntryID)).Result()
|
|
require.NoError(t, err)
|
|
require.Greater(t, ttl, 71*time.Hour)
|
|
require.LessOrEqual(t, ttl, 72*time.Hour)
|
|
}
|
|
|
|
func TestStreamOffsetStoreLoadAndSave(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := newTestRedisClient(t, server)
|
|
|
|
store, err := NewStreamOffsetStore(client)
|
|
require.NoError(t, err)
|
|
|
|
_, found, err := store.Load(context.Background(), "notification:intents")
|
|
require.NoError(t, err)
|
|
require.False(t, found)
|
|
|
|
require.NoError(t, store.Save(context.Background(), "notification:intents", "1775121700000-0"))
|
|
|
|
entryID, found, err := store.Load(context.Background(), "notification:intents")
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
require.Equal(t, "1775121700000-0", entryID)
|
|
}
|
|
|
|
func TestIntentStreamLagReaderReadsOldestUnprocessedEntry(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := newTestRedisClient(t, server)
|
|
|
|
store, err := NewStreamOffsetStore(client)
|
|
require.NoError(t, err)
|
|
reader, err := NewIntentStreamLagReader(store, "notification:intents")
|
|
require.NoError(t, err)
|
|
|
|
firstID, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
|
Stream: "notification:intents",
|
|
ID: "1775121700000-0",
|
|
Values: map[string]any{"payload": "first"},
|
|
}).Result()
|
|
require.NoError(t, err)
|
|
secondID, err := client.XAdd(context.Background(), &redis.XAddArgs{
|
|
Stream: "notification:intents",
|
|
ID: "1775121701000-0",
|
|
Values: map[string]any{"payload": "second"},
|
|
}).Result()
|
|
require.NoError(t, err)
|
|
|
|
snapshot, err := reader.ReadIntentStreamLagSnapshot(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, snapshot.OldestUnprocessedAt)
|
|
require.Equal(t, time.UnixMilli(1775121700000).UTC(), *snapshot.OldestUnprocessedAt)
|
|
|
|
require.NoError(t, store.Save(context.Background(), "notification:intents", firstID))
|
|
snapshot, err = reader.ReadIntentStreamLagSnapshot(context.Background())
|
|
require.NoError(t, err)
|
|
require.NotNil(t, snapshot.OldestUnprocessedAt)
|
|
require.Equal(t, time.UnixMilli(1775121701000).UTC(), *snapshot.OldestUnprocessedAt)
|
|
|
|
require.NoError(t, store.Save(context.Background(), "notification:intents", secondID))
|
|
snapshot, err = reader.ReadIntentStreamLagSnapshot(context.Background())
|
|
require.NoError(t, err)
|
|
require.Nil(t, snapshot.OldestUnprocessedAt)
|
|
}
|
|
|
|
func TestAcceptanceStoreWorksWithAcceptIntentService(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
server := miniredis.RunT(t)
|
|
client := newTestRedisClient(t, server)
|
|
|
|
store, err := NewAcceptanceStore(client, AcceptanceConfig{
|
|
RecordTTL: 24 * time.Hour,
|
|
DeadLetterTTL: 72 * time.Hour,
|
|
IdempotencyTTL: 7 * 24 * time.Hour,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
service, err := acceptintent.New(acceptintent.Config{
|
|
Store: store,
|
|
UserDirectory: staticUserDirectory{},
|
|
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
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(), acceptintent.AcceptInput{
|
|
NotificationID: "1775121700000-0",
|
|
Intent: 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"}`,
|
|
},
|
|
})
|
|
require.NoError(t, err)
|
|
require.Equal(t, acceptintent.OutcomeAccepted, result.Outcome)
|
|
|
|
record, found, err := store.GetNotification(context.Background(), "1775121700000-0")
|
|
require.NoError(t, err)
|
|
require.True(t, found)
|
|
require.Equal(t, "1775121700000-0", record.NotificationID)
|
|
}
|
|
|
|
type fixedClock struct {
|
|
now time.Time
|
|
}
|
|
|
|
func (clock fixedClock) Now() time.Time {
|
|
return clock.now
|
|
}
|
|
|
|
func validAdminAcceptanceInput(now time.Time) acceptintent.CreateAcceptanceInput {
|
|
return acceptintent.CreateAcceptanceInput{
|
|
Notification: acceptintent.NotificationRecord{
|
|
NotificationID: "1775121700000-0",
|
|
NotificationType: intentstream.NotificationTypeLobbyApplicationSubmitted,
|
|
Producer: intentstream.ProducerGameLobby,
|
|
AudienceKind: intentstream.AudienceKindAdminEmail,
|
|
PayloadJSON: `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"}`,
|
|
IdempotencyKey: "game-456:application-submitted:user-42",
|
|
RequestFingerprint: "sha256:deadbeef",
|
|
OccurredAt: now,
|
|
AcceptedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
Routes: []acceptintent.NotificationRoute{
|
|
{
|
|
NotificationID: "1775121700000-0",
|
|
RouteID: "push:email:owner@example.com",
|
|
Channel: intentstream.ChannelPush,
|
|
RecipientRef: "email:owner@example.com",
|
|
Status: acceptintent.RouteStatusSkipped,
|
|
AttemptCount: 0,
|
|
MaxAttempts: 3,
|
|
ResolvedEmail: "owner@example.com",
|
|
ResolvedLocale: "en",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
SkippedAt: now,
|
|
},
|
|
{
|
|
NotificationID: "1775121700000-0",
|
|
RouteID: "email:email:owner@example.com",
|
|
Channel: intentstream.ChannelEmail,
|
|
RecipientRef: "email:owner@example.com",
|
|
Status: acceptintent.RouteStatusPending,
|
|
AttemptCount: 0,
|
|
MaxAttempts: 7,
|
|
NextAttemptAt: now,
|
|
ResolvedEmail: "owner@example.com",
|
|
ResolvedLocale: "en",
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
},
|
|
},
|
|
Idempotency: acceptintent.IdempotencyRecord{
|
|
Producer: intentstream.ProducerGameLobby,
|
|
IdempotencyKey: "game-456:application-submitted:user-42",
|
|
NotificationID: "1775121700000-0",
|
|
RequestFingerprint: "sha256:deadbeef",
|
|
CreatedAt: now,
|
|
ExpiresAt: now.Add(7 * 24 * time.Hour),
|
|
},
|
|
}
|
|
}
|
|
|
|
func newTestRedisClient(t *testing.T, server *miniredis.Miniredis) *redis.Client {
|
|
t.Helper()
|
|
|
|
client := redis.NewClient(&redis.Options{
|
|
Addr: server.Addr(),
|
|
Protocol: 2,
|
|
DisableIdentity: true,
|
|
})
|
|
t.Cleanup(func() {
|
|
require.NoError(t, client.Close())
|
|
})
|
|
|
|
return client
|
|
}
|
|
|
|
type staticUserDirectory struct{}
|
|
|
|
func (staticUserDirectory) GetUserByID(context.Context, string) (acceptintent.UserRecord, error) {
|
|
return acceptintent.UserRecord{}, acceptintent.ErrRecipientNotFound
|
|
}
|