package app import ( "context" "encoding/json" "io" "log/slog" "net" "net/http" "net/http/httptest" "strconv" "testing" "time" redisstate "galaxy/notification/internal/adapters/redisstate" "galaxy/notification/internal/config" "github.com/alicebob/miniredis/v2" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) func TestNewRuntimeStartsProbeListenerAndStopsCleanly(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) userService := newUserLookupServer(t, func(http.ResponseWriter, *http.Request) {}) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.UserService.BaseURL = userService.URL cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.Run(runCtx) }() client := newTestHTTPClient(t) waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr) assertHTTPStatus(t, client, "http://"+cfg.InternalHTTP.Addr+"/healthz", http.StatusOK) assertHTTPStatus(t, client, "http://"+cfg.InternalHTTP.Addr+"/readyz", http.StatusOK) assertHTTPStatus(t, client, "http://"+cfg.InternalHTTP.Addr+"/metrics", http.StatusNotFound) cancel() waitForRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeFailsFastWhenRedisPingCheckFails(t *testing.T) { t.Parallel() cfg := config.DefaultConfig() cfg.Redis.Addr = mustFreeAddr(t) cfg.UserService.BaseURL = "http://127.0.0.1:18080" cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.Nil(t, runtime) require.Error(t, err) assert.ErrorContains(t, err, "ping redis") } func TestNewRuntimeAcceptsIntentThroughConsumer(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisServer.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { assert.NoError(t, redisClient.Close()) }) userService := newUserLookupServer(t, func(writer http.ResponseWriter, request *http.Request) { writeJSON(t, writer, http.StatusOK, map[string]any{ "user": map[string]any{ "email": "pilot@example.com", "preferred_language": "en-US", }, }) }) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.UserService.BaseURL = userService.URL cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.Run(runCtx) }() client := newTestHTTPClient(t) waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr) messageID, err := redisClient.XAdd(context.Background(), &redis.XAddArgs{ Stream: cfg.Streams.Intents, Values: map[string]any{ "notification_type": "game.turn.ready", "producer": "game_master", "audience_kind": "user", "recipient_user_ids_json": `["user-1"]`, "idempotency_key": "game-123:turn-ready", "occurred_at_ms": "1775121700000", "payload_json": `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, }, }).Result() require.NoError(t, err) require.Eventually(t, func() bool { payload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "email:user:user-1")).Bytes() if err != nil { return false } route, err := redisstate.UnmarshalRoute(payload) if err != nil { return false } return route.ResolvedEmail == "pilot@example.com" && route.ResolvedLocale == "en" }, time.Second, 10*time.Millisecond) cancel() waitForRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimePublishesAcceptedPushAndEmailRoutes(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisServer.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { assert.NoError(t, redisClient.Close()) }) userService := newUserLookupServer(t, func(writer http.ResponseWriter, request *http.Request) { writeJSON(t, writer, http.StatusOK, map[string]any{ "user": map[string]any{ "email": "pilot@example.com", "preferred_language": "en-US", }, }) }) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.UserService.BaseURL = userService.URL cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.Run(runCtx) }() client := newTestHTTPClient(t) waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr) messageID, err := redisClient.XAdd(context.Background(), &redis.XAddArgs{ Stream: cfg.Streams.Intents, Values: map[string]any{ "notification_type": "game.turn.ready", "producer": "game_master", "audience_kind": "user", "recipient_user_ids_json": `["user-1"]`, "idempotency_key": "game-123:turn-ready", "occurred_at_ms": "1775121700000", "payload_json": `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, "request_id": "request-1", "trace_id": "trace-1", }, }).Result() require.NoError(t, err) require.Eventually(t, func() bool { pushPayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "push:user:user-1")).Bytes() if err != nil { return false } pushRoute, err := redisstate.UnmarshalRoute(pushPayload) if err != nil { return false } emailPayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "email:user:user-1")).Bytes() if err != nil { return false } emailRoute, err := redisstate.UnmarshalRoute(emailPayload) if err != nil { return false } return pushRoute.Status == "published" && pushRoute.AttemptCount == 1 && emailRoute.Status == "published" && emailRoute.AttemptCount == 1 }, 2*time.Second, 10*time.Millisecond) pushRoutePayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "push:user:user-1")).Bytes() require.NoError(t, err) pushRoute, err := redisstate.UnmarshalRoute(pushRoutePayload) require.NoError(t, err) require.Equal(t, "published", string(pushRoute.Status)) notificationPayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Notification(messageID)).Bytes() require.NoError(t, err) notificationRecord, err := redisstate.UnmarshalNotification(notificationPayload) require.NoError(t, err) emailRoutePayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "email:user:user-1")).Bytes() require.NoError(t, err) emailRoute, err := redisstate.UnmarshalRoute(emailRoutePayload) require.NoError(t, err) require.Equal(t, "published", string(emailRoute.Status)) messages, err := redisClient.XRange(context.Background(), cfg.Streams.GatewayClientEvents, "-", "+").Result() require.NoError(t, err) require.Len(t, messages, 1) require.Equal(t, "user-1", messages[0].Values["user_id"]) require.Equal(t, "game.turn.ready", messages[0].Values["event_type"]) require.Equal(t, messageID+"/push:user:user-1", messages[0].Values["event_id"]) require.Equal(t, "request-1", messages[0].Values["request_id"]) require.Equal(t, "trace-1", messages[0].Values["trace_id"]) require.NotContains(t, messages[0].Values, "device_session_id") switch payload := messages[0].Values["payload_bytes"].(type) { case string: require.NotEmpty(t, payload) case []byte: require.NotEmpty(t, payload) default: require.Failf(t, "unexpected payload type", "payload_bytes has type %T", payload) } mailCommands, err := redisClient.XRange(context.Background(), cfg.Streams.MailDeliveryCommands, "-", "+").Result() require.NoError(t, err) require.Len(t, mailCommands, 1) require.Equal(t, messageID+"/email:user:user-1", mailCommands[0].Values["delivery_id"]) require.Equal(t, "notification", mailCommands[0].Values["source"]) require.Equal(t, "template", mailCommands[0].Values["payload_mode"]) require.Equal(t, "notification:"+messageID+"/email:user:user-1", mailCommands[0].Values["idempotency_key"]) require.Equal(t, strconv.FormatInt(notificationRecord.AcceptedAt.UnixMilli(), 10), mailCommands[0].Values["requested_at_ms"]) require.Equal(t, "request-1", mailCommands[0].Values["request_id"]) require.Equal(t, "trace-1", mailCommands[0].Values["trace_id"]) require.JSONEq(t, `{"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":[]}`, mailCommands[0].Values["payload_json"].(string), ) cancel() waitForRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimePublishesAdminEmailRouteOnlyToMailService(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisServer.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { assert.NoError(t, redisClient.Close()) }) userService := newUserLookupServer(t, func(http.ResponseWriter, *http.Request) {}) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.UserService.BaseURL = userService.URL cfg.AdminRouting.LobbyApplicationSubmitted = []string{"owner@example.com"} cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.Run(runCtx) }() client := newTestHTTPClient(t) waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr) messageID, err := redisClient.XAdd(context.Background(), &redis.XAddArgs{ Stream: cfg.Streams.Intents, Values: map[string]any{ "notification_type": "lobby.application.submitted", "producer": "game_lobby", "audience_kind": "admin_email", "idempotency_key": "game-123:application-submitted:user-42", "occurred_at_ms": "1775121700000", "payload_json": `{"applicant_name":"Nova Pilot","applicant_user_id":"user-42","game_id":"game-123","game_name":"Nebula Clash"}`, }, }).Result() require.NoError(t, err) require.Eventually(t, func() bool { payload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "email:email:owner@example.com")).Bytes() if err != nil { return false } route, err := redisstate.UnmarshalRoute(payload) if err != nil { return false } return route.Status == "published" && route.AttemptCount == 1 }, 2*time.Second, 10*time.Millisecond) pushRoutePayload, err := redisClient.Get(context.Background(), redisstate.Keyspace{}.Route(messageID, "push:email:owner@example.com")).Bytes() require.NoError(t, err) pushRoute, err := redisstate.UnmarshalRoute(pushRoutePayload) require.NoError(t, err) require.Equal(t, "skipped", string(pushRoute.Status)) mailCommands, err := redisClient.XRange(context.Background(), cfg.Streams.MailDeliveryCommands, "-", "+").Result() require.NoError(t, err) require.Len(t, mailCommands, 1) require.Equal(t, messageID+"/email:email:owner@example.com", mailCommands[0].Values["delivery_id"]) require.JSONEq(t, `{"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-123","game_name":"Nebula Clash"},"attachments":[]}`, mailCommands[0].Values["payload_json"].(string), ) gatewayMessages, err := redisClient.XRange(context.Background(), cfg.Streams.GatewayClientEvents, "-", "+").Result() require.NoError(t, err) require.Empty(t, gatewayMessages) cancel() waitForRunResult(t, runErrCh, cfg.ShutdownTimeout+2*time.Second) } func TestNewRuntimeUsesConfiguredUserServiceTimeout(t *testing.T) { t.Parallel() redisServer := miniredis.RunT(t) redisClient := redis.NewClient(&redis.Options{ Addr: redisServer.Addr(), Protocol: 2, DisableIdentity: true, }) t.Cleanup(func() { assert.NoError(t, redisClient.Close()) }) userService := newUserLookupServer(t, func(_ http.ResponseWriter, request *http.Request) { <-request.Context().Done() }) defer userService.Close() cfg := config.DefaultConfig() cfg.Redis.Addr = redisServer.Addr() cfg.UserService.BaseURL = userService.URL cfg.UserService.Timeout = 20 * time.Millisecond cfg.InternalHTTP.Addr = mustFreeAddr(t) cfg.ShutdownTimeout = 10 * time.Second cfg.IntentsReadBlockTimeout = 25 * time.Millisecond cfg.Telemetry.TracesExporter = "none" cfg.Telemetry.MetricsExporter = "none" runtime, err := NewRuntime(context.Background(), cfg, testLogger()) require.NoError(t, err) defer func() { require.NoError(t, runtime.Close()) }() runCtx, cancel := context.WithCancel(context.Background()) defer cancel() runErrCh := make(chan error, 1) go func() { runErrCh <- runtime.Run(runCtx) }() client := newTestHTTPClient(t) waitForRuntimeReady(t, client, cfg.InternalHTTP.Addr) messageID, err := redisClient.XAdd(context.Background(), &redis.XAddArgs{ Stream: cfg.Streams.Intents, Values: map[string]any{ "notification_type": "game.turn.ready", "producer": "game_master", "audience_kind": "user", "recipient_user_ids_json": `["user-1"]`, "idempotency_key": "game-123:turn-ready", "occurred_at_ms": "1775121700000", "payload_json": `{"game_id":"game-123","game_name":"Nebula Clash","turn_number":54}`, }, }).Result() require.NoError(t, err) var runErr error require.Eventually(t, func() bool { select { case runErr = <-runErrCh: return true default: return false } }, time.Second, 10*time.Millisecond) require.Error(t, runErr) require.ErrorContains(t, runErr, "context deadline exceeded") offsetStore, err := redisstate.NewStreamOffsetStore(redisClient) require.NoError(t, err) offset, found, err := offsetStore.Load(context.Background(), cfg.Streams.Intents) require.NoError(t, err) require.False(t, found) require.Empty(t, offset) _, err = redisClient.Get(context.Background(), redisstate.Keyspace{}.Notification(messageID)).Bytes() require.Error(t, err) } func testLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } func newTestHTTPClient(t *testing.T) *http.Client { t.Helper() transport := &http.Transport{DisableKeepAlives: true} t.Cleanup(transport.CloseIdleConnections) return &http.Client{ Timeout: 500 * time.Millisecond, Transport: transport, } } func waitForRuntimeReady(t *testing.T, client *http.Client, addr string) { t.Helper() require.Eventually(t, func() bool { request, err := http.NewRequest(http.MethodGet, "http://"+addr+"/readyz", nil) if err != nil { return false } response, err := client.Do(request) if err != nil { return false } defer response.Body.Close() _, _ = io.Copy(io.Discard, response.Body) return response.StatusCode == http.StatusOK }, 5*time.Second, 25*time.Millisecond, "notification runtime did not become reachable") } func waitForRunResult(t *testing.T, runErrCh <-chan error, waitTimeout time.Duration) { t.Helper() var err error require.Eventually(t, func() bool { select { case err = <-runErrCh: return true default: return false } }, waitTimeout, 10*time.Millisecond, "notification runtime did not stop") require.NoError(t, err) } func assertHTTPStatus(t *testing.T, client *http.Client, target string, want int) { t.Helper() request, err := http.NewRequest(http.MethodGet, target, nil) require.NoError(t, err) response, err := client.Do(request) require.NoError(t, err) defer response.Body.Close() _, _ = io.Copy(io.Discard, response.Body) require.Equal(t, want, response.StatusCode) } func mustFreeAddr(t *testing.T) string { t.Helper() listener, err := net.Listen("tcp", "127.0.0.1:0") require.NoError(t, err) defer func() { assert.NoError(t, listener.Close()) }() return listener.Addr().String() } func newUserLookupServer(t *testing.T, handler func(http.ResponseWriter, *http.Request)) *httptest.Server { t.Helper() return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { if request.Method != http.MethodGet { http.NotFound(writer, request) return } if request.URL.Path != "/api/v1/internal/users/user-1" { writeJSON(t, writer, http.StatusNotFound, map[string]any{ "error": map[string]any{ "code": "subject_not_found", "message": "subject not found", }, }) return } handler(writer, request) })) } func writeJSON(t *testing.T, writer http.ResponseWriter, statusCode int, payload any) { t.Helper() body, err := json.Marshal(payload) require.NoError(t, err) writer.Header().Set("Content-Type", "application/json") writer.WriteHeader(statusCode) _, err = writer.Write(body) require.NoError(t, err) }