feat: notification service
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user