feat: notification service
This commit is contained in:
@@ -0,0 +1,613 @@
|
||||
package acceptintent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"galaxy/notification/internal/api/intentstream"
|
||||
"galaxy/notification/internal/config"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestServiceAcceptsIntentAndMaterializesUserRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
"user-2": {Email: "two@example.com", PreferredLanguage: "en-US"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-2", "user-1"}, "request-123", "trace-123", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
|
||||
createInput := store.createInputs[0]
|
||||
require.Equal(t, "1775121700000-0", createInput.Notification.NotificationID)
|
||||
require.Equal(t, []string{"user-1", "user-2"}, createInput.Notification.RecipientUserIDs)
|
||||
require.Equal(t, `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, createInput.Notification.PayloadJSON)
|
||||
require.Len(t, createInput.Routes, 4)
|
||||
pushUser1 := routeByID(t, createInput.Routes, "push:user:user-1")
|
||||
emailUser1 := routeByID(t, createInput.Routes, "email:user:user-1")
|
||||
pushUser2 := routeByID(t, createInput.Routes, "push:user:user-2")
|
||||
emailUser2 := routeByID(t, createInput.Routes, "email:user:user-2")
|
||||
require.Equal(t, RouteStatusPending, pushUser1.Status)
|
||||
require.Equal(t, 3, pushUser1.MaxAttempts)
|
||||
require.Equal(t, "one@example.com", pushUser1.ResolvedEmail)
|
||||
require.Equal(t, "en", pushUser1.ResolvedLocale)
|
||||
require.Equal(t, RouteStatusPending, emailUser1.Status)
|
||||
require.Equal(t, 7, emailUser1.MaxAttempts)
|
||||
require.Equal(t, "one@example.com", emailUser1.ResolvedEmail)
|
||||
require.Equal(t, "en", emailUser1.ResolvedLocale)
|
||||
require.Equal(t, "two@example.com", pushUser2.ResolvedEmail)
|
||||
require.Equal(t, "en", pushUser2.ResolvedLocale)
|
||||
require.Equal(t, "two@example.com", emailUser2.ResolvedEmail)
|
||||
require.Equal(t, "en", emailUser2.ResolvedLocale)
|
||||
require.Equal(t, []string{"user-1", "user-2"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceTreatsEquivalentReplayAsDuplicate(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
firstInput := AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-1", "trace-1", time.UnixMilli(1775121700001).UTC()),
|
||||
}
|
||||
secondInput := AcceptInput{
|
||||
NotificationID: "1775121700001-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "request-2", "trace-2", time.UnixMilli(1775121799999).UTC()),
|
||||
}
|
||||
|
||||
firstResult, err := service.Execute(context.Background(), firstInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, firstResult.Outcome)
|
||||
|
||||
secondResult, err := service.Execute(context.Background(), secondInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeDuplicate, secondResult.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Equal(t, []string{"user-1"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceRejectsConflictOnSameIdempotencyScope(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700002-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":55}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700002).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrConflict)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesPublicLobbyApplicationAdminRoutes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
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(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validPublicApplicationIntent(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, 2)
|
||||
|
||||
pushRoute := routeByID(t, store.createInputs[0].Routes, "push:email:owner@example.com")
|
||||
emailRoute := routeByID(t, store.createInputs[0].Routes, "email:email:owner@example.com")
|
||||
|
||||
require.Equal(t, RouteStatusSkipped, pushRoute.Status)
|
||||
require.Equal(t, "owner@example.com", pushRoute.ResolvedEmail)
|
||||
require.Equal(t, "en", pushRoute.ResolvedLocale)
|
||||
require.Equal(t, RouteStatusPending, emailRoute.Status)
|
||||
require.Equal(t, "owner@example.com", emailRoute.ResolvedEmail)
|
||||
require.Equal(t, "en", emailRoute.ResolvedLocale)
|
||||
require.Empty(t, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesSyntheticAdminConfigRouteWhenListIsEmpty(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validPublicApplicationIntent(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, 1)
|
||||
|
||||
route := store.createInputs[0].Routes[0]
|
||||
require.Equal(t, "email:config:lobby.application.submitted", route.RouteID)
|
||||
require.Equal(t, RouteStatusSkipped, route.Status)
|
||||
require.Equal(t, 7, route.MaxAttempts)
|
||||
require.True(t, route.NextAttemptAt.IsZero())
|
||||
require.Empty(t, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceMaterializesChannelMatrixAndRetryBudgets(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
now := time.UnixMilli(1775121700000).UTC()
|
||||
tests := []struct {
|
||||
name string
|
||||
intent intentstream.Intent
|
||||
adminRouting config.AdminRoutingConfig
|
||||
wantRoutes map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}
|
||||
}{
|
||||
{
|
||||
name: "user push and email",
|
||||
intent: validTurnReadyIntent(
|
||||
`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`,
|
||||
[]string{"user-1"},
|
||||
"",
|
||||
"",
|
||||
now,
|
||||
),
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:user:user-1": {status: RouteStatusPending, maxAttempts: 3},
|
||||
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user email only",
|
||||
intent: intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeLobbyInviteExpired,
|
||||
Producer: intentstream.ProducerGameLobby,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: []string{"user-1"},
|
||||
IdempotencyKey: "game-123:invite-expired",
|
||||
OccurredAt: now,
|
||||
PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`,
|
||||
},
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:user:user-1": {status: RouteStatusSkipped, maxAttempts: 3},
|
||||
"email:user:user-1": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "admin email only",
|
||||
intent: intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeGeoReviewRecommended,
|
||||
Producer: intentstream.ProducerGeoProfile,
|
||||
AudienceKind: intentstream.AudienceKindAdminEmail,
|
||||
IdempotencyKey: "geo:user-1",
|
||||
OccurredAt: now,
|
||||
PayloadJSON: `{"observed_country":"DE","review_reason":"country_mismatch","usual_connection_country":"PL","user_email":"pilot@example.com","user_id":"user-1"}`,
|
||||
},
|
||||
adminRouting: config.AdminRoutingConfig{
|
||||
GeoReviewRecommended: []string{"admin@example.com"},
|
||||
},
|
||||
wantRoutes: map[string]struct {
|
||||
status RouteStatus
|
||||
maxAttempts int
|
||||
}{
|
||||
"push:email:admin@example.com": {status: RouteStatusSkipped, maxAttempts: 3},
|
||||
"email:email:admin@example.com": {status: RouteStatusPending, maxAttempts: 7},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "pilot@example.com", PreferredLanguage: "fr-FR"},
|
||||
})
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: now},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
AdminRouting: tt.adminRouting,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
result, err := service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: tt.intent,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
require.Len(t, store.createInputs, 1)
|
||||
require.Len(t, store.createInputs[0].Routes, len(tt.wantRoutes))
|
||||
|
||||
for routeID, want := range tt.wantRoutes {
|
||||
route := routeByID(t, store.createInputs[0].Routes, routeID)
|
||||
require.Equal(t, want.status, route.Status)
|
||||
require.Equal(t, want.maxAttempts, route.MaxAttempts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestServiceReturnsRecipientNotFoundForMissingUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-missing"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrRecipientNotFound)
|
||||
require.Empty(t, store.createInputs)
|
||||
require.Equal(t, []string{"user-missing"}, directory.lookups)
|
||||
}
|
||||
|
||||
func TestServiceReturnsServiceUnavailableWhenDirectoryFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(nil)
|
||||
directory.errByUserID["user-1"] = errors.New("user service unavailable")
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.ErrorIs(t, err, ErrServiceUnavailable)
|
||||
require.Empty(t, store.createInputs)
|
||||
}
|
||||
|
||||
func TestServiceRecordsIntentAndUserEnrichmentTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
store := newRecordingStore()
|
||||
directory := newStaticUserDirectory(map[string]UserRecord{
|
||||
"user-1": {Email: "one@example.com", PreferredLanguage: "en"},
|
||||
})
|
||||
telemetry := &recordingTelemetry{}
|
||||
service, err := New(Config{
|
||||
Store: store,
|
||||
UserDirectory: directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
Telemetry: telemetry,
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
input := AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
}
|
||||
result, err := service.Execute(context.Background(), input)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeAccepted, result.Outcome)
|
||||
|
||||
duplicateInput := input
|
||||
duplicateInput.NotificationID = "1775121700001-0"
|
||||
result, err = service.Execute(context.Background(), duplicateInput)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, OutcomeDuplicate, result.Outcome)
|
||||
|
||||
require.Equal(t, []intentOutcomeRecord{
|
||||
{
|
||||
notificationType: "game.turn.ready",
|
||||
producer: "game_master",
|
||||
audienceKind: "user",
|
||||
outcome: "accepted",
|
||||
},
|
||||
{
|
||||
notificationType: "game.turn.ready",
|
||||
producer: "game_master",
|
||||
audienceKind: "user",
|
||||
outcome: "duplicate",
|
||||
},
|
||||
}, telemetry.intentOutcomes)
|
||||
require.Equal(t, []userEnrichmentRecord{
|
||||
{notificationType: "game.turn.ready", result: "success"},
|
||||
}, telemetry.userEnrichment)
|
||||
}
|
||||
|
||||
func TestServiceRecordsUserEnrichmentFailureTelemetry(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
directory *staticUserDirectory
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "recipient not found",
|
||||
directory: newStaticUserDirectory(nil),
|
||||
want: "recipient_not_found",
|
||||
},
|
||||
{
|
||||
name: "service unavailable",
|
||||
directory: func() *staticUserDirectory {
|
||||
directory := newStaticUserDirectory(nil)
|
||||
directory.errByUserID["user-1"] = errors.New("user service unavailable")
|
||||
return directory
|
||||
}(),
|
||||
want: "service_unavailable",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
telemetry := &recordingTelemetry{}
|
||||
service, err := New(Config{
|
||||
Store: newRecordingStore(),
|
||||
UserDirectory: tt.directory,
|
||||
Clock: fixedClock{now: time.UnixMilli(1775121700000).UTC()},
|
||||
Telemetry: telemetry,
|
||||
PushMaxAttempts: 3,
|
||||
EmailMaxAttempts: 7,
|
||||
IdempotencyTTL: 7 * 24 * time.Hour,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Execute(context.Background(), AcceptInput{
|
||||
NotificationID: "1775121700000-0",
|
||||
Intent: validTurnReadyIntent(`{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, []string{"user-1"}, "", "", time.UnixMilli(1775121700001).UTC()),
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Equal(t, []userEnrichmentRecord{
|
||||
{notificationType: "game.turn.ready", result: tt.want},
|
||||
}, telemetry.userEnrichment)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type recordingStore struct {
|
||||
createInputs []CreateAcceptanceInput
|
||||
idempotency map[string]IdempotencyRecord
|
||||
notifications map[string]NotificationRecord
|
||||
}
|
||||
|
||||
func newRecordingStore() *recordingStore {
|
||||
return &recordingStore{
|
||||
idempotency: make(map[string]IdempotencyRecord),
|
||||
notifications: make(map[string]NotificationRecord),
|
||||
}
|
||||
}
|
||||
|
||||
func (store *recordingStore) CreateAcceptance(_ context.Context, input CreateAcceptanceInput) error {
|
||||
if err := input.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key := string(input.Idempotency.Producer) + ":" + input.Idempotency.IdempotencyKey
|
||||
if _, ok := store.idempotency[key]; ok {
|
||||
return ErrConflict
|
||||
}
|
||||
|
||||
store.createInputs = append(store.createInputs, input)
|
||||
store.idempotency[key] = input.Idempotency
|
||||
store.notifications[input.Notification.NotificationID] = input.Notification
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *recordingStore) GetIdempotency(_ context.Context, producer intentstream.Producer, idempotencyKey string) (IdempotencyRecord, bool, error) {
|
||||
record, ok := store.idempotency[string(producer)+":"+idempotencyKey]
|
||||
return record, ok, nil
|
||||
}
|
||||
|
||||
func (store *recordingStore) GetNotification(_ context.Context, notificationID string) (NotificationRecord, bool, error) {
|
||||
record, ok := store.notifications[notificationID]
|
||||
return record, ok, nil
|
||||
}
|
||||
|
||||
type fixedClock struct {
|
||||
now time.Time
|
||||
}
|
||||
|
||||
func (clock fixedClock) Now() time.Time {
|
||||
return clock.now
|
||||
}
|
||||
|
||||
func validTurnReadyIntent(payload string, recipients []string, requestID string, traceID string, occurredAt time.Time) intentstream.Intent {
|
||||
sorted := append([]string(nil), recipients...)
|
||||
if len(sorted) == 2 && sorted[0] == "user-2" {
|
||||
sorted[0], sorted[1] = sorted[1], sorted[0]
|
||||
}
|
||||
return intentstream.Intent{
|
||||
NotificationType: intentstream.NotificationTypeGameTurnReady,
|
||||
Producer: intentstream.ProducerGameMaster,
|
||||
AudienceKind: intentstream.AudienceKindUser,
|
||||
RecipientUserIDs: sorted,
|
||||
IdempotencyKey: "game-123:turn-54",
|
||||
OccurredAt: occurredAt.UTC().Truncate(time.Millisecond),
|
||||
RequestID: requestID,
|
||||
TraceID: traceID,
|
||||
PayloadJSON: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func validPublicApplicationIntent() intentstream.Intent {
|
||||
return 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"}`,
|
||||
}
|
||||
}
|
||||
|
||||
func routeByID(t *testing.T, routes []NotificationRoute, routeID string) NotificationRoute {
|
||||
t.Helper()
|
||||
|
||||
for _, route := range routes {
|
||||
if route.RouteID == routeID {
|
||||
return route
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("route %q not found", routeID)
|
||||
return NotificationRoute{}
|
||||
}
|
||||
|
||||
type staticUserDirectory struct {
|
||||
records map[string]UserRecord
|
||||
errByUserID map[string]error
|
||||
lookups []string
|
||||
}
|
||||
|
||||
func newStaticUserDirectory(records map[string]UserRecord) *staticUserDirectory {
|
||||
return &staticUserDirectory{
|
||||
records: records,
|
||||
errByUserID: make(map[string]error),
|
||||
}
|
||||
}
|
||||
|
||||
func (directory *staticUserDirectory) GetUserByID(_ context.Context, userID string) (UserRecord, error) {
|
||||
directory.lookups = append(directory.lookups, userID)
|
||||
if err, ok := directory.errByUserID[userID]; ok {
|
||||
return UserRecord{}, err
|
||||
}
|
||||
record, ok := directory.records[userID]
|
||||
if !ok {
|
||||
return UserRecord{}, ErrRecipientNotFound
|
||||
}
|
||||
|
||||
return record, nil
|
||||
}
|
||||
|
||||
type recordingTelemetry struct {
|
||||
intentOutcomes []intentOutcomeRecord
|
||||
userEnrichment []userEnrichmentRecord
|
||||
}
|
||||
|
||||
func (telemetry *recordingTelemetry) RecordIntentOutcome(_ context.Context, notificationType string, producer string, audienceKind string, outcome string) {
|
||||
telemetry.intentOutcomes = append(telemetry.intentOutcomes, intentOutcomeRecord{
|
||||
notificationType: notificationType,
|
||||
producer: producer,
|
||||
audienceKind: audienceKind,
|
||||
outcome: outcome,
|
||||
})
|
||||
}
|
||||
|
||||
func (telemetry *recordingTelemetry) RecordUserEnrichmentAttempt(_ context.Context, notificationType string, result string) {
|
||||
telemetry.userEnrichment = append(telemetry.userEnrichment, userEnrichmentRecord{
|
||||
notificationType: notificationType,
|
||||
result: result,
|
||||
})
|
||||
}
|
||||
|
||||
type intentOutcomeRecord struct {
|
||||
notificationType string
|
||||
producer string
|
||||
audienceKind string
|
||||
outcome string
|
||||
}
|
||||
|
||||
type userEnrichmentRecord struct {
|
||||
notificationType string
|
||||
result string
|
||||
}
|
||||
Reference in New Issue
Block a user