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