package publishmail import ( "encoding/json" "testing" "time" "galaxy/notification/internal/api/intentstream" "galaxy/notification/internal/service/acceptintent" "github.com/stretchr/testify/require" ) func TestEncoderEncodesUserAndAdminEmailCommands(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() tests := []struct { name string notification acceptintent.NotificationRecord route acceptintent.NotificationRoute wantDeliveryID string wantIdempotency string wantPayloadJSON string }{ { name: "user route", notification: acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeGameTurnReady, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, IdempotencyKey: "game-123:turn-54", RequestFingerprint: "sha256:deadbeef", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, route: acceptintent.NotificationRoute{ NotificationID: "1775121700000-0", RouteID: "email:user:user-1", Channel: intentstream.ChannelEmail, RecipientRef: "user:user-1", Status: acceptintent.RouteStatusPending, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "pilot@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, wantDeliveryID: "1775121700000-0/email:user:user-1", wantIdempotency: "notification:1775121700000-0/email:user:user-1", wantPayloadJSON: `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"game.turn.ready","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54},"attachments":[]}`, }, { name: "admin route", notification: acceptintent.NotificationRecord{ NotificationID: "1775121700001-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:cafebabe", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, route: acceptintent.NotificationRoute{ NotificationID: "1775121700001-0", RouteID: "email:email:owner@example.com", Channel: intentstream.ChannelEmail, RecipientRef: "email:owner@example.com", Status: acceptintent.RouteStatusPending, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "owner@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, wantDeliveryID: "1775121700001-0/email:email:owner@example.com", wantIdempotency: "notification:1775121700001-0/email:email:owner@example.com", wantPayloadJSON: `{"to":["owner@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.application.submitted","locale":"en","variables":{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-456","game_name":"Orion Front"},"attachments":[]}`, }, } for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() command, err := Encoder{}.Encode(tt.notification, tt.route) require.NoError(t, err) require.Equal(t, tt.wantDeliveryID, command.DeliveryID) require.Equal(t, tt.wantIdempotency, command.IdempotencyKey) require.Equal(t, now, command.RequestedAt) require.JSONEq(t, tt.wantPayloadJSON, command.PayloadJSON) values := command.Values() require.Equal(t, tt.wantDeliveryID, values["delivery_id"]) require.Equal(t, "notification", values["source"]) require.Equal(t, "template", values["payload_mode"]) require.Equal(t, tt.wantIdempotency, values["idempotency_key"]) require.Equal(t, "1775121700000", values["requested_at_ms"]) }) } } func TestEncoderPropagatesTracingMetadata(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() command, err := Encoder{}.Encode( acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeGameTurnReady, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, IdempotencyKey: "game-123:turn-54", RequestFingerprint: "sha256:deadbeef", RequestID: "request-1", TraceID: "trace-1", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, acceptintent.NotificationRoute{ NotificationID: "1775121700000-0", RouteID: "email:user:user-1", Channel: intentstream.ChannelEmail, RecipientRef: "user:user-1", Status: acceptintent.RouteStatusPending, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "pilot@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, ) require.NoError(t, err) values := command.Values() require.Equal(t, "request-1", values["request_id"]) require.Equal(t, "trace-1", values["trace_id"]) } func TestEncoderPreservesNormalizedPayloadAsTemplateVariables(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() command, err := Encoder{}.Encode( acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeGameFinished, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"final_turn_number":81,"game_id":"game-123","game_name":"Nebula Clash"}`, IdempotencyKey: "game-123:final", RequestFingerprint: "sha256:deadbeef", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, acceptintent.NotificationRoute{ NotificationID: "1775121700000-0", RouteID: "email:user:user-1", Channel: intentstream.ChannelEmail, RecipientRef: "user:user-1", Status: acceptintent.RouteStatusPending, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "pilot@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, ) require.NoError(t, err) var payload struct { Variables map[string]any `json:"variables"` } require.NoError(t, json.Unmarshal([]byte(command.PayloadJSON), &payload)) require.Equal(t, map[string]any{ "final_turn_number": float64(81), "game_id": "game-123", "game_name": "Nebula Clash", }, payload.Variables) } func TestEncoderUsesEmptyAncillaryEnvelopeFields(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() command, err := Encoder{}.Encode( acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeLobbyInviteExpired, Producer: intentstream.ProducerGameLobby, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"}`, IdempotencyKey: "game-123:invite-expired", RequestFingerprint: "sha256:deadbeef", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, acceptintent.NotificationRoute{ NotificationID: "1775121700000-0", RouteID: "email:user:user-1", Channel: intentstream.ChannelEmail, RecipientRef: "user:user-1", Status: acceptintent.RouteStatusPending, MaxAttempts: 7, NextAttemptAt: now, ResolvedEmail: "pilot@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, ) require.NoError(t, err) require.JSONEq( t, `{"to":["pilot@example.com"],"cc":[],"bcc":[],"reply_to":[],"template_id":"lobby.invite.expired","locale":"en","variables":{"game_id":"game-123","game_name":"Nebula Clash","invitee_name":"Nova Pilot","invitee_user_id":"user-2"},"attachments":[]}`, command.PayloadJSON, ) } func TestEncoderRejectsInvalidRouteForMailPublication(t *testing.T) { t.Parallel() now := time.UnixMilli(1775121700000).UTC() _, err := Encoder{}.Encode( acceptintent.NotificationRecord{ NotificationID: "1775121700000-0", NotificationType: intentstream.NotificationTypeGameTurnReady, Producer: intentstream.ProducerGameMaster, AudienceKind: intentstream.AudienceKindUser, RecipientUserIDs: []string{"user-1"}, PayloadJSON: `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, IdempotencyKey: "game-123:turn-54", RequestFingerprint: "sha256:deadbeef", AcceptedAt: now, OccurredAt: now, UpdatedAt: now, }, acceptintent.NotificationRoute{ NotificationID: "1775121700000-0", RouteID: "push:user:user-1", Channel: intentstream.ChannelPush, RecipientRef: "user:user-1", Status: acceptintent.RouteStatusPending, MaxAttempts: 3, NextAttemptAt: now, ResolvedEmail: "pilot@example.com", ResolvedLocale: "en", CreatedAt: now, UpdatedAt: now, }, ) require.Error(t, err) require.ErrorContains(t, err, `route channel "push" is unsupported`) }