package notificationuser_test import ( "bytes" "context" "encoding/base64" "encoding/json" "errors" "io" "net/http" "testing" "time" "galaxy/integration/internal/harness" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) const notificationUserIntentsStream = "notification:intents" func TestNotificationUserEnrichmentPersistsResolvedRecipient(t *testing.T) { h := newNotificationUserHarness(t) recipient := h.ensureUser(t, "pilot@example.com", "fr-FR") messageID := h.publishUserIntent(t, recipient.UserID, "game.turn.ready", "game_master", "enrichment-success", `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`) route := h.waitForRoute(t, messageID, "email:user:"+recipient.UserID) require.Equal(t, messageID, route.NotificationID) require.Equal(t, "email:user:"+recipient.UserID, route.RouteID) require.Equal(t, "email", route.Channel) require.Equal(t, "user:"+recipient.UserID, route.RecipientRef) require.Equal(t, "pilot@example.com", route.ResolvedEmail) require.Equal(t, "en", route.ResolvedLocale) offset := h.waitForStreamOffset(t) require.Equal(t, messageID, offset.LastProcessedEntryID) } func TestNotificationUserMissingRecipientIsMalformedAndAdvancesOffset(t *testing.T) { h := newNotificationUserHarness(t) messageID := h.publishUserIntent(t, "user-missing", "game.turn.ready", "game_master", "missing-user", `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`) malformed := h.waitForMalformedIntent(t, messageID) require.Equal(t, messageID, malformed.StreamEntryID) require.Equal(t, "game.turn.ready", malformed.NotificationType) require.Equal(t, "game_master", malformed.Producer) require.Equal(t, "recipient_not_found", malformed.FailureCode) offset := h.waitForStreamOffset(t) require.Equal(t, messageID, offset.LastProcessedEntryID) } func TestNotificationUserTemporaryUnavailabilityDoesNotAdvanceOffset(t *testing.T) { h := newNotificationUserHarness(t) recipient := h.ensureUser(t, "temporary@example.com", "en") h.notificationProcess.AllowUnexpectedExit() h.userServiceProcess.Stop(t) messageID := h.publishUserIntent(t, recipient.UserID, "game.turn.ready", "game_master", "temporary-user-service", `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`) require.Never(t, func() bool { offset, ok := h.loadStreamOffset(t) return ok && offset.LastProcessedEntryID == messageID }, time.Second, 50*time.Millisecond) exists, err := h.redis.Exists(context.Background(), notificationMalformedIntentKey(messageID)).Result() require.NoError(t, err) require.Zero(t, exists) exists, err = h.redis.Exists(context.Background(), notificationRouteKey(messageID, "email:user:"+recipient.UserID)).Result() require.NoError(t, err) require.Zero(t, exists) } type notificationUserHarness struct { redis *redis.Client userServiceURL string notificationProcess *harness.Process userServiceProcess *harness.Process } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id"` } type notificationRouteRecord struct { NotificationID string `json:"notification_id"` RouteID string `json:"route_id"` Channel string `json:"channel"` RecipientRef string `json:"recipient_ref"` Status string `json:"status"` ResolvedEmail string `json:"resolved_email,omitempty"` ResolvedLocale string `json:"resolved_locale,omitempty"` } type malformedIntentRecord struct { StreamEntryID string `json:"stream_entry_id"` NotificationType string `json:"notification_type,omitempty"` Producer string `json:"producer,omitempty"` IdempotencyKey string `json:"idempotency_key,omitempty"` FailureCode string `json:"failure_code"` FailureMessage string `json:"failure_message"` RawFields map[string]any `json:"raw_fields_json"` RecordedAtMS int64 `json:"recorded_at_ms"` } type streamOffsetRecord struct { Stream string `json:"stream"` LastProcessedEntryID string `json:"last_processed_entry_id"` UpdatedAtMS int64 `json:"updated_at_ms"` } type httpResponse struct { StatusCode int Body string Header http.Header } func newNotificationUserHarness(t *testing.T) *notificationUserHarness { t.Helper() redisRuntime := harness.StartRedisContainer(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisRuntime.Addr, Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { require.NoError(t, redisClient.Close()) }) userServiceAddr := harness.FreeTCPAddress(t) notificationInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") notificationBinary := harness.BuildBinary(t, "notification", "./notification/cmd/notification") userServiceProcess := harness.StartProcess(t, "userservice", userServiceBinary, map[string]string{ "USERSERVICE_LOG_LEVEL": "info", "USERSERVICE_INTERNAL_HTTP_ADDR": userServiceAddr, "USERSERVICE_REDIS_ADDR": redisRuntime.Addr, "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForUserServiceReady(t, userServiceProcess, "http://"+userServiceAddr) notificationProcess := harness.StartProcess(t, "notification", notificationBinary, map[string]string{ "NOTIFICATION_LOG_LEVEL": "info", "NOTIFICATION_INTERNAL_HTTP_ADDR": notificationInternalAddr, "NOTIFICATION_REDIS_ADDR": redisRuntime.Addr, "NOTIFICATION_USER_SERVICE_BASE_URL": "http://" + userServiceAddr, "NOTIFICATION_USER_SERVICE_TIMEOUT": "250ms", "NOTIFICATION_INTENTS_READ_BLOCK_TIMEOUT": "100ms", "NOTIFICATION_ROUTE_BACKOFF_MIN": "100ms", "NOTIFICATION_ROUTE_BACKOFF_MAX": "100ms", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) harness.WaitForHTTPStatus(t, notificationProcess, "http://"+notificationInternalAddr+"/readyz", http.StatusOK) return ¬ificationUserHarness{ redis: redisClient, userServiceURL: "http://" + userServiceAddr, notificationProcess: notificationProcess, userServiceProcess: userServiceProcess, } } func (h *notificationUserHarness) ensureUser(t *testing.T, email string, preferredLanguage string) ensureByEmailResponse { t.Helper() response := postJSONValue(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{ "email": email, "registration_context": map[string]string{ "preferred_language": preferredLanguage, "time_zone": "Europe/Kaliningrad", }, }) var body ensureByEmailResponse requireJSONStatus(t, response, http.StatusOK, &body) require.Equal(t, "created", body.Outcome) require.NotEmpty(t, body.UserID) return body } func (h *notificationUserHarness) publishUserIntent(t *testing.T, recipientUserID string, notificationType string, producer string, idempotencyKey string, payloadJSON string) string { t.Helper() messageID, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{ Stream: notificationUserIntentsStream, Values: map[string]any{ "notification_type": notificationType, "producer": producer, "audience_kind": "user", "recipient_user_ids_json": `["` + recipientUserID + `"]`, "idempotency_key": idempotencyKey, "occurred_at_ms": "1775121700000", "payload_json": payloadJSON, }, }).Result() require.NoError(t, err) return messageID } func (h *notificationUserHarness) waitForRoute(t *testing.T, notificationID string, routeID string) notificationRouteRecord { t.Helper() key := notificationRouteKey(notificationID, routeID) var route notificationRouteRecord require.Eventually(t, func() bool { payload, err := h.redis.Get(context.Background(), key).Bytes() if err != nil { return false } require.NoError(t, decodeJSONPayload(payload, &route)) return true }, 10*time.Second, 50*time.Millisecond) return route } func (h *notificationUserHarness) waitForMalformedIntent(t *testing.T, streamEntryID string) malformedIntentRecord { t.Helper() key := notificationMalformedIntentKey(streamEntryID) var record malformedIntentRecord require.Eventually(t, func() bool { payload, err := h.redis.Get(context.Background(), key).Bytes() if err != nil { return false } require.NoError(t, decodeStrictJSONPayload(payload, &record)) return true }, 10*time.Second, 50*time.Millisecond) return record } func (h *notificationUserHarness) waitForStreamOffset(t *testing.T) streamOffsetRecord { t.Helper() var offset streamOffsetRecord require.Eventually(t, func() bool { var ok bool offset, ok = h.loadStreamOffset(t) return ok }, 10*time.Second, 50*time.Millisecond) return offset } func (h *notificationUserHarness) loadStreamOffset(t *testing.T) (streamOffsetRecord, bool) { t.Helper() payload, err := h.redis.Get(context.Background(), notificationStreamOffsetKey()).Bytes() if errors.Is(err, redis.Nil) { return streamOffsetRecord{}, false } require.NoError(t, err) var offset streamOffsetRecord require.NoError(t, decodeStrictJSONPayload(payload, &offset)) return offset, true } func waitForUserServiceReady(t *testing.T, process *harness.Process, baseURL string) { t.Helper() client := &http.Client{Timeout: 250 * time.Millisecond} t.Cleanup(client.CloseIdleConnections) deadline := time.Now().Add(10 * time.Second) for time.Now().Before(deadline) { request, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-missing/exists", nil) require.NoError(t, err) response, err := client.Do(request) if err == nil { _, _ = io.Copy(io.Discard, response.Body) response.Body.Close() if response.StatusCode == http.StatusOK { return } } time.Sleep(25 * time.Millisecond) } t.Fatalf("wait for userservice readiness: timeout\n%s", process.Logs()) } func postJSONValue(t *testing.T, targetURL string, body any) httpResponse { t.Helper() payload, err := json.Marshal(body) require.NoError(t, err) request, err := http.NewRequest(http.MethodPost, targetURL, bytes.NewReader(payload)) require.NoError(t, err) request.Header.Set("Content-Type", "application/json") return doRequest(t, request) } func requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) { t.Helper() require.Equal(t, wantStatus, response.StatusCode, "response body: %s", response.Body) require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), target)) } func doRequest(t *testing.T, request *http.Request) httpResponse { t.Helper() client := &http.Client{ Timeout: 5 * time.Second, Transport: &http.Transport{ DisableKeepAlives: true, }, } t.Cleanup(client.CloseIdleConnections) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() payload, err := io.ReadAll(response.Body) require.NoError(t, err) return httpResponse{ StatusCode: response.StatusCode, Body: string(payload), Header: response.Header.Clone(), } } func decodeStrictJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) decoder.DisallowUnknownFields() if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } func decodeJSONPayload(payload []byte, target any) error { decoder := json.NewDecoder(bytes.NewReader(payload)) if err := decoder.Decode(target); err != nil { return err } if err := decoder.Decode(&struct{}{}); err != io.EOF { if err == nil { return errors.New("unexpected trailing JSON input") } return err } return nil } func notificationRouteKey(notificationID string, routeID string) string { return "notification:routes:" + encodeKeyComponent(notificationID) + ":" + encodeKeyComponent(routeID) } func notificationMalformedIntentKey(streamEntryID string) string { return "notification:malformed_intents:" + encodeKeyComponent(streamEntryID) } func notificationStreamOffsetKey() string { return "notification:stream_offsets:" + encodeKeyComponent(notificationUserIntentsStream) } func encodeKeyComponent(value string) string { return base64.RawURLEncoding.EncodeToString([]byte(value)) }