package integration_test import ( "context" "net/http" "strings" "testing" "time" "galaxy/integration/testenv" ) // TestNotificationFlow_LobbyInvite asserts that a `lobby.invite.received` // intent triggers a push frame on the gateway SubscribeEvents stream // for the invitee AND a captured email at mailpit. func TestNotificationFlow_LobbyInvite(t *testing.T) { plat := testenv.Bootstrap(t, testenv.BootstrapOptions{}) ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() // Register an engine version so private-game creation can pass // validation. admin := testenv.NewBackendAdminClient(plat.Backend.HTTPURL, plat.Backend.AdminUser, plat.Backend.AdminPassword) if _, resp, err := admin.Do(ctx, http.MethodPost, "/api/v1/admin/engine-versions", map[string]any{ "version": "v1.0.0", "image_ref": "galaxy/game:integration", "enabled": true, }); err != nil || resp.StatusCode/100 != 2 { t.Fatalf("seed engine_version: err=%v resp=%v", err, resp) } inviter := testenv.RegisterSession(t, plat, "inviter@example.com") invitee := testenv.RegisterSession(t, plat, "invitee@example.com") inviterUser, err := inviter.LookupUserID(ctx, plat) if err != nil { t.Fatalf("resolve inviter user_id: %v", err) } inviteeUser, err := invitee.LookupUserID(ctx, plat) if err != nil { t.Fatalf("resolve invitee user_id: %v", err) } // Inviter creates a private game. inviterClient := testenv.NewBackendUserClient(plat.Backend.HTTPURL, inviterUser) gameBody := map[string]any{ "game_name": "Private Sortie", "visibility": "private", "min_players": 2, "max_players": 4, "start_gap_hours": 1, "start_gap_players": 2, "enrollment_ends_at": time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), "turn_schedule": "0 * * * *", "target_engine_version": "v1.0.0", } raw, resp, err := inviterClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games", gameBody) if err != nil || resp.StatusCode != http.StatusCreated { t.Fatalf("create private game: err=%v status=%d body=%s", err, resp.StatusCode, string(raw)) } var game struct { GameID string `json:"game_id"` } if err := decodeJSON(raw, &game); err != nil { t.Fatalf("decode game: %v", err) } // Invitee opens SubscribeEvents stream BEFORE the invite is // issued so we cannot miss the push frame. gw, err := invitee.DialAuthenticated(ctx, plat) if err != nil { t.Fatalf("invitee dial: %v", err) } defer gw.Close() streamCtx, streamCancel := context.WithCancel(ctx) defer streamCancel() events, errCh, err := gw.SubscribeEvents(streamCtx, "gateway.subscribe") if err != nil { t.Fatalf("subscribe events: %v", err) } // Drain the bootstrap server-time event before the test gets // going so the invite event is the next thing observed. select { case <-events: case err := <-errCh: t.Fatalf("subscribe stream error before invite: %v", err) case <-time.After(5 * time.Second): t.Fatalf("bootstrap event not received within 5s") } // Now clear mailpit so we can detect the new invite email. if err := plat.Mailpit.DeleteAll(ctx); err != nil { t.Fatalf("clear mailpit: %v", err) } // Inviter issues an invite for invitee. inviteBody := map[string]any{ "invited_user_id": inviteeUser, "race_name": "Invitee-Crew", } raw, resp, err = inviterClient.Do(ctx, http.MethodPost, "/api/v1/user/lobby/games/"+game.GameID+"/invites", inviteBody) if err != nil || resp.StatusCode != http.StatusCreated { t.Fatalf("issue invite: err=%v status=%d body=%s", err, resp.StatusCode, string(raw)) } // Push: expect a non-bootstrap event. pushDeadline := time.After(20 * time.Second) gotPush := false PUSH: for { select { case ev, ok := <-events: if !ok { break PUSH } if ev == nil || ev.GetEventType() == "gateway.server_time" { continue } gotPush = true break PUSH case err := <-errCh: t.Fatalf("subscribe stream error during invite: %v", err) case <-pushDeadline: break PUSH } } if !gotPush { t.Fatalf("no push event received for lobby invite within 20s") } // Email: expect mailpit to receive a message addressed to invitee. if _, err := plat.Mailpit.WaitForMessage(ctx, "to:"+invitee.Email, 30*time.Second); err != nil { t.Fatalf("invite email not captured: %v", err) } _ = strings.TrimSpace } func decodeJSON(raw []byte, v any) error { return jsonUnmarshal(raw, v) }