feat: game lobby service

This commit is contained in:
Ilia Denisov
2026-04-25 23:20:55 +02:00
committed by GitHub
parent 32dc29359a
commit 48b0056b49
336 changed files with 57074 additions and 1418 deletions
@@ -0,0 +1,635 @@
// Package lobbynotification_test exercises Lobby's notification-intent
// publication boundary by booting Lobby + the real User Service against a
// Redis container and asserting on the contents of `notification:intents`.
// The Notification Service is intentionally NOT booted: the boundary under
// test is "Lobby produces correct intent envelopes onto the stream",
// independent of how the Notification Service consumes them.
package lobbynotification_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"maps"
"net/http"
"net/http/httptest"
"slices"
"strconv"
"strings"
"sync/atomic"
"testing"
"time"
"galaxy/integration/internal/harness"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
const (
notificationIntentsStream = "notification:intents"
userLifecycleStream = "user:lifecycle_events"
runtimeJobResultsStream = "runtime:job_results"
gmLobbyEventsStream = "gm:lobby_events"
intentTypeApplicationSubmitted = "lobby.application.submitted"
intentTypeMembershipApproved = "lobby.membership.approved"
intentTypeMembershipRejected = "lobby.membership.rejected"
intentTypeMembershipBlocked = "lobby.membership.blocked"
intentTypeInviteCreated = "lobby.invite.created"
intentTypeInviteRedeemed = "lobby.invite.redeemed"
intentTypeInviteExpired = "lobby.invite.expired"
intentTypeRuntimePausedAfter = "lobby.runtime_paused_after_start"
expectedProducer = "game_lobby"
)
func TestApplicationFlowPublishesSubmittedApprovedRejected(t *testing.T) {
h := newLobbyNotificationHarness(t, gmAlwaysOK)
applicantA := h.ensureUser(t, "applicantA@example.com")
applicantB := h.ensureUser(t, "applicantB@example.com")
gameID := h.adminCreatePublicGame(t, "Application Galaxy", time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
appA := h.submitApplication(t, applicantA.UserID, gameID, "PilotAlpha")
h.adminApproveApplication(t, gameID, appA["application_id"].(string))
appB := h.submitApplication(t, applicantB.UserID, gameID, "PilotBeta")
h.adminRejectApplication(t, gameID, appB["application_id"].(string))
h.requireIntents(t,
expect(intentTypeApplicationSubmitted, "admin"),
expect(intentTypeApplicationSubmitted, "admin"),
expect(intentTypeMembershipApproved, applicantA.UserID),
expect(intentTypeMembershipRejected, applicantB.UserID),
)
}
func TestPrivateInviteLifecyclePublishesCreatedRedeemedExpired(t *testing.T) {
h := newLobbyNotificationHarness(t, gmAlwaysOK)
owner := h.ensureUser(t, "owner@example.com")
inviteeA := h.ensureUser(t, "inviteeA@example.com")
inviteeB := h.ensureUser(t, "inviteeB@example.com")
gameID := h.userCreatePrivateGame(t, owner.UserID, "Private Invite Galaxy",
time.Now().Add(48*time.Hour).Unix())
h.userOpenEnrollment(t, owner.UserID, gameID)
h.userCreateInvite(t, owner.UserID, gameID, inviteeA.UserID)
inviteB := h.userCreateInvite(t, owner.UserID, gameID, inviteeB.UserID)
_ = inviteB
// Read invitee A's invite ID by listing their invites.
inviteAID := h.firstCreatedInviteID(t, inviteeA.UserID, gameID)
h.userRedeemInvite(t, inviteeA.UserID, gameID, inviteAID, "PilotPrivateA")
// Close enrollment (min_players=1 satisfied by inviteeA's redeem).
// Invite B is still in `created` and must transition to `expired`.
h.userReadyToStart(t, owner.UserID, gameID)
h.requireIntents(t,
expect(intentTypeInviteCreated, inviteeA.UserID),
expect(intentTypeInviteCreated, inviteeB.UserID),
expect(intentTypeInviteRedeemed, owner.UserID),
expect(intentTypeInviteExpired, owner.UserID),
)
}
func TestCascadeMembershipBlockedPublishesIntent(t *testing.T) {
h := newLobbyNotificationHarness(t, gmAlwaysOK)
owner := h.ensureUser(t, "cascade-owner@example.com")
invitee := h.ensureUser(t, "cascade-invitee@example.com")
gameID := h.userCreatePrivateGame(t, owner.UserID, "Cascade Galaxy",
time.Now().Add(48*time.Hour).Unix())
h.userOpenEnrollment(t, owner.UserID, gameID)
h.userCreateInvite(t, owner.UserID, gameID, invitee.UserID)
inviteID := h.firstCreatedInviteID(t, invitee.UserID, gameID)
h.userRedeemInvite(t, invitee.UserID, gameID, inviteID, "PilotCascade")
h.publishUserLifecycleEvent(t, "user.lifecycle.permanent_blocked", invitee.UserID)
h.requireIntents(t,
expect(intentTypeInviteCreated, invitee.UserID),
expect(intentTypeInviteRedeemed, owner.UserID),
expect(intentTypeMembershipBlocked, owner.UserID),
)
}
func TestRuntimePausedAfterStartPublishesAdminIntent(t *testing.T) {
gmRegisterFails := func(w http.ResponseWriter, r *http.Request) {
if strings.Contains(r.URL.Path, "/register-runtime") {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte(`{"error":"forced GM unavailability"}`))
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}
h := newLobbyNotificationHarness(t, gmRegisterFails)
applicant := h.ensureUser(t, "starter@example.com")
gameID := h.adminCreatePublicGame(t, "Runtime Pause Galaxy",
time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
app := h.submitApplication(t, applicant.UserID, gameID, "PilotPause")
h.adminApproveApplication(t, gameID, app["application_id"].(string))
h.adminReadyToStart(t, gameID)
h.adminStartGame(t, gameID)
h.publishRuntimeJobSuccess(t, gameID)
h.requireIntents(t,
expect(intentTypeApplicationSubmitted, "admin"),
expect(intentTypeMembershipApproved, applicant.UserID),
expect(intentTypeRuntimePausedAfter, "admin"),
)
}
type lobbyNotificationHarness struct {
redis *redis.Client
userServiceURL string
lobbyPublicURL string
lobbyAdminURL string
intentsStream string
lifecycleStream string
jobResultsStream string
gmEventsStream string
gmStub *httptest.Server
userServiceProcess *harness.Process
lobbyProcess *harness.Process
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id"`
}
type expectedIntent struct {
NotificationType string
Recipient string // user_id, or "admin" for admin_email audience
}
func expect(notificationType, recipient string) expectedIntent {
return expectedIntent{NotificationType: notificationType, Recipient: recipient}
}
func gmAlwaysOK(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}
var harnessSeq atomic.Int64
func newLobbyNotificationHarness(t *testing.T, gmHandler http.HandlerFunc) *lobbyNotificationHarness {
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())
})
gmStub := httptest.NewServer(http.HandlerFunc(gmHandler))
t.Cleanup(gmStub.Close)
userServiceAddr := harness.FreeTCPAddress(t)
lobbyPublicAddr := harness.FreeTCPAddress(t)
lobbyInternalAddr := harness.FreeTCPAddress(t)
userServiceBinary := harness.BuildBinary(t, "userservice", "./user/cmd/userservice")
lobbyBinary := harness.BuildBinary(t, "lobby", "./lobby/cmd/lobby")
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)
// Use unique stream prefixes per test so concurrent runs do not bleed.
suffix := strconv.FormatInt(harnessSeq.Add(1), 10)
intentsStream := notificationIntentsStream + ":" + suffix
lifecycleStream := userLifecycleStream + ":" + suffix
jobResultsStream := runtimeJobResultsStream + ":" + suffix
gmEventsStream := gmLobbyEventsStream + ":" + suffix
lobbyProcess := harness.StartProcess(t, "lobby", lobbyBinary, map[string]string{
"LOBBY_LOG_LEVEL": "info",
"LOBBY_PUBLIC_HTTP_ADDR": lobbyPublicAddr,
"LOBBY_INTERNAL_HTTP_ADDR": lobbyInternalAddr,
"LOBBY_REDIS_ADDR": redisRuntime.Addr,
"LOBBY_USER_SERVICE_BASE_URL": "http://" + userServiceAddr,
"LOBBY_GM_BASE_URL": gmStub.URL,
"LOBBY_NOTIFICATION_INTENTS_STREAM": intentsStream,
"LOBBY_USER_LIFECYCLE_STREAM": lifecycleStream,
"LOBBY_RUNTIME_JOB_RESULTS_STREAM": jobResultsStream,
"LOBBY_GM_EVENTS_STREAM": gmEventsStream,
"LOBBY_RUNTIME_JOB_RESULTS_READ_BLOCK_TIMEOUT": "200ms",
"LOBBY_USER_LIFECYCLE_READ_BLOCK_TIMEOUT": "200ms",
"LOBBY_GM_EVENTS_READ_BLOCK_TIMEOUT": "200ms",
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK)
return &lobbyNotificationHarness{
redis: redisClient,
userServiceURL: "http://" + userServiceAddr,
lobbyPublicURL: "http://" + lobbyPublicAddr,
lobbyAdminURL: "http://" + lobbyInternalAddr,
intentsStream: intentsStream,
lifecycleStream: lifecycleStream,
jobResultsStream: jobResultsStream,
gmEventsStream: gmEventsStream,
gmStub: gmStub,
userServiceProcess: userServiceProcess,
lobbyProcess: lobbyProcess,
}
}
func (h *lobbyNotificationHarness) ensureUser(t *testing.T, email string) ensureByEmailResponse {
t.Helper()
resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/ensure-by-email", map[string]any{
"email": email,
"registration_context": map[string]string{
"preferred_language": "en",
"time_zone": "Europe/Kaliningrad",
},
}, nil)
var out ensureByEmailResponse
requireJSONStatus(t, resp, http.StatusOK, &out)
require.Equal(t, "created", out.Outcome)
require.NotEmpty(t, out.UserID)
return out
}
func (h *lobbyNotificationHarness) adminCreatePublicGame(t *testing.T, name string, enrollmentEndsAt int64) string {
t.Helper()
return h.createGame(t, h.lobbyAdminURL+"/api/v1/lobby/games", "public", name, enrollmentEndsAt, nil)
}
func (h *lobbyNotificationHarness) userCreatePrivateGame(t *testing.T, ownerUserID, name string, enrollmentEndsAt int64) string {
t.Helper()
return h.createGame(t, h.lobbyPublicURL+"/api/v1/lobby/games", "private", name, enrollmentEndsAt,
http.Header{"X-User-Id": []string{ownerUserID}})
}
func (h *lobbyNotificationHarness) createGame(t *testing.T, url, gameType, name string, enrollmentEndsAt int64, header http.Header) string {
t.Helper()
resp := postJSON(t, url, map[string]any{
"game_name": name,
"game_type": gameType,
"min_players": 1,
"max_players": 4,
"start_gap_hours": 6,
"start_gap_players": 1,
"enrollment_ends_at": enrollmentEndsAt,
"turn_schedule": "0 18 * * *",
"target_engine_version": "1.0.0",
}, header)
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create %s game: %s", gameType, resp.Body)
var record map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Body), &record))
gameID, ok := record["game_id"].(string)
require.Truef(t, ok, "game_id missing: %s", resp.Body)
return gameID
}
func (h *lobbyNotificationHarness) openEnrollment(t *testing.T, gameID string) {
t.Helper()
resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "admin open enrollment: %s", resp.Body)
}
func (h *lobbyNotificationHarness) userOpenEnrollment(t *testing.T, ownerUserID, gameID string) {
t.Helper()
resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/open-enrollment", nil,
http.Header{"X-User-Id": []string{ownerUserID}})
require.Equalf(t, http.StatusOK, resp.StatusCode, "user open enrollment: %s", resp.Body)
}
func (h *lobbyNotificationHarness) submitApplication(t *testing.T, userID, gameID, raceName string) map[string]any {
t.Helper()
resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/applications",
map[string]any{"race_name": raceName},
http.Header{"X-User-Id": []string{userID}})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "submit application: %s", resp.Body)
var body map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))
return body
}
func (h *lobbyNotificationHarness) adminApproveApplication(t *testing.T, gameID, applicationID string) {
t.Helper()
resp := postJSON(t,
h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/applications/"+applicationID+"/approve",
nil, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "admin approve: %s", resp.Body)
}
func (h *lobbyNotificationHarness) adminRejectApplication(t *testing.T, gameID, applicationID string) {
t.Helper()
resp := postJSON(t,
h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/applications/"+applicationID+"/reject",
nil, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "admin reject: %s", resp.Body)
}
func (h *lobbyNotificationHarness) userCreateInvite(t *testing.T, ownerUserID, gameID, inviteeUserID string) map[string]any {
t.Helper()
resp := postJSON(t, h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites",
map[string]any{"invitee_user_id": inviteeUserID},
http.Header{"X-User-Id": []string{ownerUserID}})
require.Equalf(t, http.StatusCreated, resp.StatusCode, "create invite: %s", resp.Body)
var body map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))
return body
}
func (h *lobbyNotificationHarness) firstCreatedInviteID(t *testing.T, inviteeUserID, gameID string) string {
t.Helper()
req, err := http.NewRequest(http.MethodGet, h.lobbyPublicURL+"/api/v1/lobby/my/invites?status=created", nil)
require.NoError(t, err)
req.Header.Set("X-User-Id", inviteeUserID)
resp := doRequest(t, req)
require.Equalf(t, http.StatusOK, resp.StatusCode, "list my invites: %s", resp.Body)
var body struct {
Items []struct {
InviteID string `json:"invite_id"`
GameID string `json:"game_id"`
} `json:"items"`
}
require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))
for _, item := range body.Items {
if item.GameID == gameID {
return item.InviteID
}
}
t.Fatalf("no invite found for invitee %s on game %s; body=%s", inviteeUserID, gameID, resp.Body)
return ""
}
func (h *lobbyNotificationHarness) userRedeemInvite(t *testing.T, inviteeUserID, gameID, inviteID, raceName string) {
t.Helper()
resp := postJSON(t,
h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/invites/"+inviteID+"/redeem",
map[string]any{"race_name": raceName},
http.Header{"X-User-Id": []string{inviteeUserID}})
require.Equalf(t, http.StatusOK, resp.StatusCode, "redeem invite: %s", resp.Body)
}
func (h *lobbyNotificationHarness) userReadyToStart(t *testing.T, ownerUserID, gameID string) {
t.Helper()
resp := postJSON(t,
h.lobbyPublicURL+"/api/v1/lobby/games/"+gameID+"/ready-to-start",
nil,
http.Header{"X-User-Id": []string{ownerUserID}})
require.Equalf(t, http.StatusOK, resp.StatusCode, "user ready-to-start: %s", resp.Body)
}
func (h *lobbyNotificationHarness) adminReadyToStart(t *testing.T, gameID string) {
t.Helper()
resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/ready-to-start", nil, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "admin ready-to-start: %s", resp.Body)
}
func (h *lobbyNotificationHarness) adminStartGame(t *testing.T, gameID string) {
t.Helper()
resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games/"+gameID+"/start", nil, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "admin start game: %s", resp.Body)
}
func (h *lobbyNotificationHarness) publishUserLifecycleEvent(t *testing.T, eventType, userID string) {
t.Helper()
_, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{
Stream: h.lifecycleStream,
Values: map[string]any{
"event_type": eventType,
"user_id": userID,
"occurred_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10),
"source": "user_admin",
"actor_type": "admin",
"actor_id": "admin-1",
"reason_code": "terminal_policy_violation",
},
}).Result()
require.NoError(t, err)
}
func (h *lobbyNotificationHarness) publishRuntimeJobSuccess(t *testing.T, gameID string) {
t.Helper()
_, err := h.redis.XAdd(context.Background(), &redis.XAddArgs{
Stream: h.jobResultsStream,
Values: map[string]any{
"game_id": gameID,
"outcome": "success",
"container_id": "container-" + gameID,
"engine_endpoint": "127.0.0.1:0",
},
}).Result()
require.NoError(t, err)
}
func (h *lobbyNotificationHarness) requireIntents(t *testing.T, want ...expectedIntent) {
t.Helper()
want = append([]expectedIntent(nil), want...)
require.Eventuallyf(t, func() bool {
entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result()
if err != nil {
return false
}
published := decodePublishedIntents(t, entries)
return matchesAll(published, want)
}, 15*time.Second, 100*time.Millisecond,
"expected intents %+v not all observed on stream %s", want, h.intentsStream)
entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result()
require.NoError(t, err)
published := decodePublishedIntents(t, entries)
for _, p := range published {
require.Equal(t, expectedProducer, p.Producer,
"every published intent must declare producer=%q", expectedProducer)
}
}
type publishedIntent struct {
NotificationType string
Producer string
AudienceKind string
RecipientUserIDs []string
}
func decodePublishedIntents(t *testing.T, entries []redis.XMessage) []publishedIntent {
t.Helper()
out := make([]publishedIntent, 0, len(entries))
for _, entry := range entries {
notificationType, _ := entry.Values["notification_type"].(string)
producer, _ := entry.Values["producer"].(string)
audienceKind, _ := entry.Values["audience_kind"].(string)
recipientsJSON, _ := entry.Values["recipient_user_ids_json"].(string)
var recipients []string
if recipientsJSON != "" {
require.NoError(t, json.Unmarshal([]byte(recipientsJSON), &recipients))
}
out = append(out, publishedIntent{
NotificationType: notificationType,
Producer: producer,
AudienceKind: audienceKind,
RecipientUserIDs: recipients,
})
}
return out
}
func matchesAll(published []publishedIntent, want []expectedIntent) bool {
used := make([]bool, len(published))
for _, w := range want {
matched := -1
for i, p := range published {
if used[i] {
continue
}
if p.NotificationType != w.NotificationType {
continue
}
if w.Recipient == "admin" {
if p.AudienceKind == "admin_email" {
matched = i
break
}
continue
}
if slices.Contains(p.RecipientUserIDs, w.Recipient) {
matched = i
break
}
}
if matched < 0 {
return false
}
used[matched] = true
}
return 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) {
req, err := http.NewRequest(http.MethodGet, baseURL+"/api/v1/internal/users/user-readiness-probe/exists", nil)
require.NoError(t, err)
response, err := client.Do(req)
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())
}
type httpResponse struct {
StatusCode int
Body string
Header http.Header
}
func postJSON(t *testing.T, url string, body any, header http.Header) httpResponse {
t.Helper()
var reader io.Reader
if body != nil {
payload, err := json.Marshal(body)
require.NoError(t, err)
reader = bytes.NewReader(payload)
}
req, err := http.NewRequest(http.MethodPost, url, reader)
require.NoError(t, err)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
maps.Copy(req.Header, header)
return doRequest(t, req)
}
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 requireJSONStatus(t *testing.T, response httpResponse, wantStatus int, target any) {
t.Helper()
require.Equalf(t, wantStatus, response.StatusCode, "unexpected status, body=%s", response.Body)
if target != nil {
require.NoError(t, decodeStrictJSON([]byte(response.Body), target))
}
}
func decodeStrictJSON(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
}
// silenceUnused keeps fmt referenced by future debug formatting needs.
var _ = fmt.Sprintf
@@ -0,0 +1,198 @@
// Race-name intent tests cover the three notification types Lobby emits
// across the capability-evaluation and self-service registration boundary:
//
// - lobby.race_name.registration_eligible — produced when a member's
// stats satisfy the capability rule at game finish;
// - lobby.race_name.registration_denied — produced when they do not;
// - lobby.race_name.registered — produced when the user converts the
// pending registration into a permanent registered name.
//
// The single test below drives a public game through start, publishes the
// `gm:lobby_events` snapshot and `game_finished` events directly to Redis,
// then performs the user-side registration call. Notification Service is
// not booted: the assertion target is the contents of `notification:intents`.
package lobbynotification_test
import (
"context"
"encoding/json"
"net/http"
"slices"
"strconv"
"testing"
"time"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
const (
intentTypeRaceNameEligible = "lobby.race_name.registration_eligible"
intentTypeRaceNameDenied = "lobby.race_name.registration_denied"
intentTypeRaceNameRegistered = "lobby.race_name.registered"
)
func TestRaceNameIntentsAcrossCapabilityAndRegistration(t *testing.T) {
h := newLobbyNotificationHarness(t, gmAlwaysOK)
capableUser := h.ensureUser(t, "race-capable@example.com")
incapableUser := h.ensureUser(t, "race-incapable@example.com")
gameID := h.adminCreatePublicGame(t, "Race Name Galaxy",
time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
capableApp := h.submitApplication(t, capableUser.UserID, gameID, "Capable")
h.adminApproveApplication(t, gameID, capableApp["application_id"].(string))
incapableApp := h.submitApplication(t, incapableUser.UserID, gameID, "Incapable")
h.adminApproveApplication(t, gameID, incapableApp["application_id"].(string))
h.adminReadyToStart(t, gameID)
h.adminStartGame(t, gameID)
h.publishRuntimeJobSuccess(t, gameID)
// Wait for runtime job result + GM register-runtime to flip the game
// to `running` before publishing GM stream events. Otherwise the
// `game_finished` transition guard in the gmevents consumer rejects
// the event for an unexpected status.
h.requireGameStatus(t, gameID, "running")
// First snapshot freezes initial stats for both members.
h.publishGMSnapshotUpdate(t, gameID, []playerTurnStat{
{UserID: capableUser.UserID, Planets: 1, Population: 100},
{UserID: incapableUser.UserID, Planets: 1, Population: 100},
})
// game_finished bumps capable user's stats above the initial values
// and leaves the incapable user unchanged. Capability rule is
// `max_planets > initial_planets AND max_population > initial_population`.
h.publishGMGameFinished(t, gameID, []playerTurnStat{
{UserID: capableUser.UserID, Planets: 10, Population: 1000},
{UserID: incapableUser.UserID, Planets: 1, Population: 100},
})
// Capability evaluation runs asynchronously after the game_finished
// event is consumed. Wait for the registration_eligible intent to
// appear before attempting the user-side register call: the call only
// succeeds once the pending registration is recorded.
h.requireGameStatus(t, gameID, "finished")
h.waitForIntent(t, intentTypeRaceNameEligible, capableUser.UserID)
h.userRegisterRaceName(t, capableUser.UserID, gameID, "Capable")
h.requireIntents(t,
expect(intentTypeApplicationSubmitted, "admin"),
expect(intentTypeApplicationSubmitted, "admin"),
expect(intentTypeMembershipApproved, capableUser.UserID),
expect(intentTypeMembershipApproved, incapableUser.UserID),
expect(intentTypeRaceNameEligible, capableUser.UserID),
expect(intentTypeRaceNameDenied, incapableUser.UserID),
expect(intentTypeRaceNameRegistered, capableUser.UserID),
)
}
type playerTurnStat struct {
UserID string `json:"user_id"`
Planets int64 `json:"planets"`
Population int64 `json:"population"`
ShipsBuilt int64 `json:"ships_built"`
}
func (h *lobbyNotificationHarness) publishGMSnapshotUpdate(t *testing.T, gameID string, stats []playerTurnStat) {
t.Helper()
payload, err := json.Marshal(stats)
require.NoError(t, err)
_, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{
Stream: h.gmEventsStream,
Values: map[string]any{
"kind": "runtime_snapshot_update",
"game_id": gameID,
"current_turn": "1",
"runtime_status": "healthy",
"engine_health_summary": "ok",
"player_turn_stats": string(payload),
},
}).Result()
require.NoError(t, err)
}
func (h *lobbyNotificationHarness) publishGMGameFinished(t *testing.T, gameID string, stats []playerTurnStat) {
t.Helper()
payload, err := json.Marshal(stats)
require.NoError(t, err)
_, err = h.redis.XAdd(context.Background(), &redis.XAddArgs{
Stream: h.gmEventsStream,
Values: map[string]any{
"kind": "game_finished",
"game_id": gameID,
"finished_at_ms": strconv.FormatInt(time.Now().UnixMilli(), 10),
"current_turn": "10",
"runtime_status": "finished",
"engine_health_summary": "ok",
"player_turn_stats": string(payload),
},
}).Result()
require.NoError(t, err)
}
func (h *lobbyNotificationHarness) requireGameStatus(t *testing.T, gameID, want string) {
t.Helper()
require.Eventuallyf(t, func() bool {
req, err := http.NewRequest(http.MethodGet,
h.lobbyAdminURL+"/api/v1/internal/games/"+gameID, nil)
if err != nil {
return false
}
resp := doRequest(t, req)
if resp.StatusCode != http.StatusOK {
return false
}
var record map[string]any
if err := json.Unmarshal([]byte(resp.Body), &record); err != nil {
return false
}
status, _ := record["status"].(string)
return status == want
}, 15*time.Second, 100*time.Millisecond,
"game %s did not reach status %s", gameID, want)
}
func (h *lobbyNotificationHarness) waitForIntent(t *testing.T, notificationType, recipient string) {
t.Helper()
require.Eventuallyf(t, func() bool {
entries, err := h.redis.XRange(context.Background(), h.intentsStream, "-", "+").Result()
if err != nil {
return false
}
published := decodePublishedIntents(t, entries)
for _, p := range published {
if p.NotificationType != notificationType {
continue
}
if recipient == "admin" {
if p.AudienceKind == "admin_email" {
return true
}
continue
}
if slices.Contains(p.RecipientUserIDs, recipient) {
return true
}
}
return false
}, 15*time.Second, 100*time.Millisecond,
"intent %s for %s not observed on stream %s",
notificationType, recipient, h.intentsStream)
}
func (h *lobbyNotificationHarness) userRegisterRaceName(t *testing.T, userID, sourceGameID, raceName string) {
t.Helper()
resp := postJSON(t,
h.lobbyPublicURL+"/api/v1/lobby/race-names/register",
map[string]any{
"race_name": raceName,
"source_game_id": sourceGameID,
},
http.Header{"X-User-Id": []string{userID}})
require.Equalf(t, http.StatusOK, resp.StatusCode, "register race name: %s", resp.Body)
}