feat: notification service

This commit is contained in:
Ilia Denisov
2026-04-22 08:49:45 +02:00
committed by GitHub
parent 5b7593e6f6
commit 32dc29359a
135 changed files with 21828 additions and 130 deletions
@@ -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
}