package notificationmail_test import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net/http" "net/url" "path/filepath" "runtime" "testing" "time" "galaxy/integration/internal/harness" "github.com/redis/go-redis/v9" "github.com/stretchr/testify/require" ) const ( notificationMailDeliveriesPath = "/api/v1/internal/deliveries" notificationMailIntentsStream = "notification:intents" ) func TestNotificationMailPublishesEveryTemplateModeDeliveryToRealMailService(t *testing.T) { h := newNotificationMailHarness(t) recipient := h.ensureUser(t, "pilot@example.com", "fr-FR") cases := []mailIntentCase{ { name: "geo review recommended admin", notificationType: "geo.review_recommended", producer: "geoprofile", audienceKind: "admin_email", recipientEmail: "geo-admin@example.com", routeID: "email:email:geo-admin@example.com", payload: map[string]any{ "user_id": "user-geo", "user_email": "traveler@example.com", "observed_country": "DE", "usual_connection_country": "PL", "review_reason": "country_mismatch", }, }, { name: "game turn ready user", notificationType: "game.turn.ready", producer: "game_master", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "turn_number": 54, }, }, { name: "game finished user", notificationType: "game.finished", producer: "game_master", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "final_turn_number": 55, }, }, { name: "game generation failed admin", notificationType: "game.generation_failed", producer: "game_master", audienceKind: "admin_email", recipientEmail: "game-admin@example.com", routeID: "email:email:game-admin@example.com", payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "failure_reason": "engine_timeout", }, }, { name: "lobby runtime paused admin", notificationType: "lobby.runtime_paused_after_start", producer: "game_lobby", audienceKind: "admin_email", recipientEmail: "lobby-ops@example.com", routeID: "email:email:lobby-ops@example.com", payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", }, }, { name: "lobby application submitted user", notificationType: "lobby.application.submitted", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "applicant_user_id": "applicant-1", "applicant_name": "Nova Pilot", }, }, { name: "lobby application submitted admin", notificationType: "lobby.application.submitted", producer: "game_lobby", audienceKind: "admin_email", recipientEmail: "lobby-admin@example.com", routeID: "email:email:lobby-admin@example.com", payload: map[string]any{ "game_id": "game-456", "game_name": "Public Stars", "applicant_user_id": "applicant-2", "applicant_name": "Public Pilot", }, }, { name: "lobby membership approved user", notificationType: "lobby.membership.approved", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", }, }, { name: "lobby membership rejected user", notificationType: "lobby.membership.rejected", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", }, }, { name: "lobby invite created user", notificationType: "lobby.invite.created", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "inviter_user_id": "owner-1", "inviter_name": "Owner Pilot", }, }, { name: "lobby invite redeemed user", notificationType: "lobby.invite.redeemed", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "invitee_user_id": "invitee-1", "invitee_name": "Nova Pilot", }, }, { name: "lobby invite expired user", notificationType: "lobby.invite.expired", producer: "game_lobby", audienceKind: "user", recipientEmail: recipient.Email, payload: map[string]any{ "game_id": "game-123", "game_name": "Nebula Clash", "invitee_user_id": "invitee-1", "invitee_name": "Nova Pilot", }, }, } for index, tc := range cases { tc := tc t.Run(tc.name, func(t *testing.T) { messageID := h.publishMailIntent(t, tc, recipient.UserID, index) routeID := tc.routeID if routeID == "" { routeID = "email:user:" + recipient.UserID } idempotencyKey := "notification:" + messageID + "/" + routeID list := h.eventuallyListDeliveries(t, url.Values{ "source": []string{"notification"}, "status": []string{"sent"}, "recipient": []string{tc.recipientEmail}, "template_id": []string{tc.notificationType}, "idempotency_key": []string{idempotencyKey}, }) require.Len(t, list.Items, 1) require.Equal(t, "notification", list.Items[0].Source) require.Equal(t, "sent", list.Items[0].Status) require.Equal(t, "template", list.Items[0].PayloadMode) require.Equal(t, tc.notificationType, list.Items[0].TemplateID) require.Equal(t, "en", list.Items[0].Locale) require.Equal(t, []string{tc.recipientEmail}, list.Items[0].To) detail := h.getDelivery(t, list.Items[0].DeliveryID) require.Equal(t, "notification", detail.Source) require.Equal(t, "template", detail.PayloadMode) require.Equal(t, tc.notificationType, detail.TemplateID) require.Equal(t, "en", detail.Locale) require.False(t, detail.LocaleFallbackUsed) require.Equal(t, idempotencyKey, detail.IdempotencyKey) require.Equal(t, []string{tc.recipientEmail}, detail.To) require.Empty(t, detail.Cc) require.Empty(t, detail.Bcc) require.Empty(t, detail.ReplyTo) require.Empty(t, detail.Attachments) assertTemplateVariables(t, tc.payload, detail.TemplateVariables) }) } } type notificationMailHarness struct { redis *redis.Client userServiceURL string mailBaseURL string notificationProcess *harness.Process mailProcess *harness.Process userServiceProcess *harness.Process } type mailIntentCase struct { name string notificationType string producer string audienceKind string recipientEmail string routeID string payload map[string]any } type ensureByEmailResponse struct { Outcome string `json:"outcome"` UserID string `json:"user_id"` Email string } type mailDeliveryListResponse struct { Items []mailDeliverySummary `json:"items"` } type mailDeliverySummary struct { DeliveryID string `json:"delivery_id"` Source string `json:"source"` PayloadMode string `json:"payload_mode"` TemplateID string `json:"template_id"` Locale string `json:"locale"` LocaleFallbackUsed bool `json:"locale_fallback_used"` To []string `json:"to"` Cc []string `json:"cc"` Bcc []string `json:"bcc"` ReplyTo []string `json:"reply_to"` IdempotencyKey string `json:"idempotency_key"` Status string `json:"status"` AttemptCount int `json:"attempt_count"` LastAttemptStatus string `json:"last_attempt_status,omitempty"` ProviderSummary string `json:"provider_summary,omitempty"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` SentAtMS int64 `json:"sent_at_ms,omitempty"` } type mailDeliveryDetailResponse struct { DeliveryID string `json:"delivery_id"` Source string `json:"source"` PayloadMode string `json:"payload_mode"` TemplateID string `json:"template_id"` Locale string `json:"locale"` LocaleFallbackUsed bool `json:"locale_fallback_used"` To []string `json:"to"` Cc []string `json:"cc"` Bcc []string `json:"bcc"` ReplyTo []string `json:"reply_to"` Subject string `json:"subject,omitempty"` TextBody string `json:"text_body,omitempty"` HTMLBody string `json:"html_body,omitempty"` Attachments []any `json:"attachments"` IdempotencyKey string `json:"idempotency_key"` Status string `json:"status"` AttemptCount int `json:"attempt_count"` LastAttemptStatus string `json:"last_attempt_status,omitempty"` ProviderSummary string `json:"provider_summary,omitempty"` TemplateVariables map[string]any `json:"template_variables,omitempty"` CreatedAtMS int64 `json:"created_at_ms"` UpdatedAtMS int64 `json:"updated_at_ms"` SentAtMS int64 `json:"sent_at_ms,omitempty"` } type httpResponse struct { StatusCode int Body string Header http.Header } func newNotificationMailHarness(t *testing.T) *notificationMailHarness { 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) mailInternalAddr := harness.FreeTCPAddress(t) notificationInternalAddr := harness.FreeTCPAddress(t) userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice") mailBinary := harness.BuildBinary(t, "mail", "./mail/cmd/mail") 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) mailProcess := harness.StartProcess(t, "mail", mailBinary, map[string]string{ "MAIL_LOG_LEVEL": "info", "MAIL_INTERNAL_HTTP_ADDR": mailInternalAddr, "MAIL_REDIS_ADDR": redisRuntime.Addr, "MAIL_TEMPLATE_DIR": mailTemplateDir(t), "MAIL_SMTP_MODE": "stub", "MAIL_STREAM_BLOCK_TIMEOUT": "100ms", "MAIL_OPERATOR_REQUEST_TIMEOUT": time.Second.String(), "MAIL_SHUTDOWN_TIMEOUT": "2s", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) waitForMailReady(t, mailProcess, "http://"+mailInternalAddr) 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": time.Second.String(), "NOTIFICATION_INTENTS_READ_BLOCK_TIMEOUT": "100ms", "NOTIFICATION_ROUTE_BACKOFF_MIN": "100ms", "NOTIFICATION_ROUTE_BACKOFF_MAX": "100ms", "NOTIFICATION_ADMIN_EMAILS_GEO_REVIEW_RECOMMENDED": "geo-admin@example.com", "NOTIFICATION_ADMIN_EMAILS_GAME_GENERATION_FAILED": "game-admin@example.com", "NOTIFICATION_ADMIN_EMAILS_LOBBY_RUNTIME_PAUSED_AFTER_START": "lobby-ops@example.com", "NOTIFICATION_ADMIN_EMAILS_LOBBY_APPLICATION_SUBMITTED": "lobby-admin@example.com", "OTEL_TRACES_EXPORTER": "none", "OTEL_METRICS_EXPORTER": "none", }) harness.WaitForHTTPStatus(t, notificationProcess, "http://"+notificationInternalAddr+"/readyz", http.StatusOK) return ¬ificationMailHarness{ redis: redisClient, userServiceURL: "http://" + userServiceAddr, mailBaseURL: "http://" + mailInternalAddr, notificationProcess: notificationProcess, mailProcess: mailProcess, userServiceProcess: userServiceProcess, } } func (h *notificationMailHarness) 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) body.Email = email return body } func (h *notificationMailHarness) publishMailIntent(t *testing.T, tc mailIntentCase, recipientUserID string, index int) string { t.Helper() payload, err := json.Marshal(tc.payload) require.NoError(t, err) values := map[string]any{ "notification_type": tc.notificationType, "producer": tc.producer, "audience_kind": tc.audienceKind, "idempotency_key": fmt.Sprintf("%s:mail:%02d", tc.notificationType, index), "occurred_at_ms": "1775121700000", "payload_json": string(payload), } if tc.audienceKind == "user" { values["recipient_user_ids_json"] = `["` + recipientUserID + `"]` } messageID, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{ Stream: notificationMailIntentsStream, Values: values, }).Result() require.NoError(t, err) return messageID } func (h *notificationMailHarness) eventuallyListDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse { t.Helper() var response mailDeliveryListResponse require.Eventually(t, func() bool { response = h.listDeliveries(t, query) return len(response.Items) > 0 }, 10*time.Second, 50*time.Millisecond) return response } func (h *notificationMailHarness) listDeliveries(t *testing.T, query url.Values) mailDeliveryListResponse { t.Helper() target := h.mailBaseURL + notificationMailDeliveriesPath if encoded := query.Encode(); encoded != "" { target += "?" + encoded } request, err := http.NewRequest(http.MethodGet, target, nil) require.NoError(t, err) return doJSONRequest[mailDeliveryListResponse](t, request, http.StatusOK) } func (h *notificationMailHarness) getDelivery(t *testing.T, deliveryID string) mailDeliveryDetailResponse { t.Helper() request, err := http.NewRequest(http.MethodGet, h.mailBaseURL+notificationMailDeliveriesPath+"/"+url.PathEscape(deliveryID), nil) require.NoError(t, err) return doJSONRequest[mailDeliveryDetailResponse](t, request, http.StatusOK) } 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 waitForMailReady(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+notificationMailDeliveriesPath, 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 mail readiness: timeout\n%s", process.Logs()) } func doJSONRequest[T any](t *testing.T, request *http.Request, wantStatus int) T { t.Helper() response := doRequest(t, request) require.Equal(t, wantStatus, response.StatusCode, response.Body) var decoded T require.NoError(t, decodeStrictJSONPayload([]byte(response.Body), &decoded), response.Body) return decoded } 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 assertTemplateVariables(t *testing.T, want map[string]any, got map[string]any) { t.Helper() require.NotEmpty(t, got) for key, wantValue := range want { gotValue, ok := got[key] require.Truef(t, ok, "template variable %q is missing", key) switch typedWant := wantValue.(type) { case string: require.Equal(t, typedWant, gotValue) case int: require.Equal(t, float64(typedWant), gotValue) default: require.Equal(t, typedWant, gotValue) } } } func mailTemplateDir(t *testing.T) string { t.Helper() return filepath.Join(repositoryRoot(t), "mail", "templates") } func repositoryRoot(t *testing.T) string { t.Helper() _, file, _, ok := runtime.Caller(0) if !ok { t.Fatal("resolve repository root: runtime caller is unavailable") } return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) }