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
+23 -4
View File
@@ -34,6 +34,11 @@ integration/
│ └── notification_mail_test.go
├── notificationuser/
│ └── notification_user_test.go
├── lobbyuser/
│ └── lobby_user_test.go
├── lobbynotification/
│ ├── lobby_notification_test.go
│ └── race_name_intents_test.go
├── go.mod
├── go.sum
└── internal/
@@ -81,14 +86,24 @@ integration/
across real `Edge Gateway`, real `Auth / Session Service`, real
`User Service`, and real `Mail Service`, including the regression that
auth-code mail bypasses `notification:intents`.
- `lobbyuser` verifies the synchronous eligibility boundary between real
`Game Lobby` and real `User Service`, including the happy path,
permanent_block rejection, unknown user, and transient User Service
unavailability.
- `lobbynotification` verifies the producer side of `Game Lobby →
notification:intents`, covering all eleven `lobby.*` intent types from
applications, invites, member operations, runtime pause, cascade
membership block, and the three race-name intents emitted by capability
evaluation at game finish and by self-service registration.
The current fast suites still use one isolated `miniredis` instance plus either
real downstream processes or external stateful HTTP stubs where appropriate.
`authsessionmail`, `gatewayauthsessionmail`, `notificationgateway`,
`notificationmail`, `notificationuser`, and `gatewayauthsessionusermail` are
the deliberate exceptions: they use one real Redis container through
`testcontainers-go`, because those boundaries must exercise real Redis stream,
persistence, or scheduling behavior.
`notificationmail`, `notificationuser`, `gatewayauthsessionusermail`,
`lobbyuser`, and `lobbynotification` are the deliberate exceptions: they use
one real Redis container through `testcontainers-go`, because those
boundaries must exercise real Redis stream, persistence, or scheduling
behavior.
`authsessionmail` additionally contains one targeted SMTP-capture scenario for
the real `smtp` provider path, while `gatewayauthsessionmail` keeps `Mail
Service` in `stub` mode and extracts the confirmation code through the trusted
@@ -110,6 +125,8 @@ go test ./notificationgateway/...
go test ./notificationmail/...
go test ./notificationuser/...
go test ./gatewayauthsessionusermail/...
go test ./lobbyuser/...
go test ./lobbynotification/...
```
Useful regression commands after boundary changes:
@@ -125,6 +142,8 @@ go test ./notificationgateway/...
go test ./notificationmail/...
go test ./notificationuser/...
go test ./gatewayauthsessionusermail/...
go test ./lobbyuser/...
go test ./lobbynotification/...
cd ../gateway && go test ./...
cd ../authsession && go test ./... -run GatewayCompatibility
cd ../user && go test ./...
@@ -30,7 +30,8 @@ func TestAuthsessionUserBlackBoxConfirmCreatesUserWithForwardedRegistrationConte
require.Equal(t, "en", account.User.PreferredLanguage)
require.Equal(t, testTimeZone, account.User.TimeZone)
require.True(t, strings.HasPrefix(account.User.UserID, "user-"))
require.True(t, strings.HasPrefix(account.User.RaceName, "player-"))
require.True(t, strings.HasPrefix(account.User.UserName, "player-"))
require.Empty(t, account.User.DisplayName)
require.Equal(t, "free", account.User.Entitlement.PlanCode)
require.False(t, account.User.Entitlement.IsPaid)
require.Empty(t, account.User.ActiveSanctions)
+2 -1
View File
@@ -333,7 +333,8 @@ type userLookupResponse struct {
type accountView struct {
UserID string `json:"user_id"`
Email string `json:"email"`
RaceName string `json:"race_name"`
UserName string `json:"user_name"`
DisplayName string `json:"display_name,omitempty"`
PreferredLanguage string `json:"preferred_language"`
TimeZone string `json:"time_zone"`
DeclaredCountry string `json:"declared_country,omitempty"`
+5 -4
View File
@@ -50,7 +50,7 @@ func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) {
clientPrivateKey := newClientPrivateKey("update-profile")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Nova Prime")
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("NovaPrime")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
@@ -58,10 +58,11 @@ func TestGatewayUserUpdateMyProfileSuccess(t *testing.T) {
accountResponse, err := contractsuserv1.DecodeAccountResponse(response.GetPayloadBytes())
require.NoError(t, err)
require.Equal(t, "Nova Prime", accountResponse.Account.RaceName)
require.Equal(t, "NovaPrime", accountResponse.Account.DisplayName)
require.NotEmpty(t, accountResponse.Account.UserName)
lookup := h.lookupUserByEmail(t, email)
require.Equal(t, "Nova Prime", lookup.User.RaceName)
require.Equal(t, "NovaPrime", lookup.User.DisplayName)
}
func TestGatewayUserUpdateMySettingsSuccess(t *testing.T) {
@@ -108,7 +109,7 @@ func TestGatewayUserUpdateMyProfileConflict(t *testing.T) {
clientPrivateKey := newClientPrivateKey("profile-conflict")
h.seedGatewaySession(t, deviceSessionID, created.UserID, clientPrivateKey)
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("Blocked Nova")
payload, err := contractsuserv1.EncodeUpdateMyProfileRequest("BlockedNova")
require.NoError(t, err)
response := h.executeCommand(t, deviceSessionID, requestID, contractsuserv1.MessageTypeUpdateMyProfile, payload, clientPrivateKey)
@@ -33,9 +33,9 @@ func EncodeGetMyAccountRequest() ([]byte, error) {
// EncodeUpdateMyProfileRequest returns the FlatBuffers payload for one public
// self-service profile mutation request.
func EncodeUpdateMyProfileRequest(raceName string) ([]byte, error) {
func EncodeUpdateMyProfileRequest(displayName string) ([]byte, error) {
return transcoder.UpdateMyProfileRequestToPayload(&usermodel.UpdateMyProfileRequest{
RaceName: raceName,
DisplayName: displayName,
})
}
@@ -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)
}
+325
View File
@@ -0,0 +1,325 @@
// Package lobbyuser_test exercises the synchronous Lobby → User Service
// eligibility boundary by running both binaries in-process against a real
// Redis container. The Game Master client surface is satisfied by an
// inline httptest stub because the eligibility flow does not touch GM.
package lobbyuser_test
import (
"bytes"
"encoding/json"
"errors"
"io"
"maps"
"net/http"
"net/http/httptest"
"testing"
"time"
"galaxy/integration/internal/harness"
"github.com/stretchr/testify/require"
)
func TestEligibilityCapturedOnApplication(t *testing.T) {
h := newLobbyUserHarness(t)
user := h.ensureUser(t, "happy@example.com")
gameID := h.adminCreatePublicGame(t, "Happy Path Galaxy", time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
app := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotAurora", http.StatusCreated)
require.NotEmpty(t, app["application_id"])
require.Equal(t, gameID, app["game_id"])
require.Equal(t, user.UserID, app["applicant_user_id"])
require.Equal(t, "PilotAurora", app["race_name"])
require.Equal(t, "submitted", app["status"])
}
func TestEligibilityRejectedForPermanentlyBlockedUser(t *testing.T) {
h := newLobbyUserHarness(t)
user := h.ensureUser(t, "blocked@example.com")
h.applyPermanentBlock(t, user.UserID)
gameID := h.adminCreatePublicGame(t, "Block Galaxy", time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotEclipse", http.StatusUnprocessableEntity)
requireErrorCode(t, body, "eligibility_denied")
}
func TestEligibilityRejectedForUnknownUser(t *testing.T) {
h := newLobbyUserHarness(t)
gameID := h.adminCreatePublicGame(t, "Unknown Galaxy", time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
body := h.submitApplicationExpectStatus(t, "user-does-not-exist", gameID, "PilotPhantom", http.StatusUnprocessableEntity)
requireErrorCode(t, body, "eligibility_denied")
}
func TestEligibilityFailsWhenUserServiceDown(t *testing.T) {
h := newLobbyUserHarness(t)
user := h.ensureUser(t, "transient@example.com")
gameID := h.adminCreatePublicGame(t, "Transient Galaxy", time.Now().Add(48*time.Hour).Unix())
h.openEnrollment(t, gameID)
h.userServiceProcess.Stop(t)
body := h.submitApplicationExpectStatus(t, user.UserID, gameID, "PilotOutage", http.StatusServiceUnavailable)
requireErrorCode(t, body, "service_unavailable")
}
type lobbyUserHarness struct {
userServiceURL string
lobbyPublicURL string
lobbyAdminURL string
gmStub *httptest.Server
userServiceProcess *harness.Process
lobbyProcess *harness.Process
}
type ensureByEmailResponse struct {
Outcome string `json:"outcome"`
UserID string `json:"user_id"`
}
func newLobbyUserHarness(t *testing.T) *lobbyUserHarness {
t.Helper()
redisRuntime := harness.StartRedisContainer(t)
gmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{}`))
}))
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)
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,
"OTEL_TRACES_EXPORTER": "none",
"OTEL_METRICS_EXPORTER": "none",
})
harness.WaitForHTTPStatus(t, lobbyProcess, "http://"+lobbyInternalAddr+"/readyz", http.StatusOK)
return &lobbyUserHarness{
userServiceURL: "http://" + userServiceAddr,
lobbyPublicURL: "http://" + lobbyPublicAddr,
lobbyAdminURL: "http://" + lobbyInternalAddr,
gmStub: gmStub,
userServiceProcess: userServiceProcess,
lobbyProcess: lobbyProcess,
}
}
func (h *lobbyUserHarness) 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 *lobbyUserHarness) applyPermanentBlock(t *testing.T, userID string) {
t.Helper()
resp := postJSON(t, h.userServiceURL+"/api/v1/internal/users/"+userID+"/sanctions/apply", map[string]any{
"sanction_code": "permanent_block",
"scope": "platform",
"reason_code": "terminal_policy_violation",
"actor": map[string]string{"type": "admin", "id": "admin-1"},
"applied_at": time.Now().UTC().Format(time.RFC3339),
}, nil)
require.Equalf(t, http.StatusOK, resp.StatusCode, "apply permanent_block: %s", resp.Body)
}
func (h *lobbyUserHarness) adminCreatePublicGame(t *testing.T, name string, enrollmentEndsAt int64) string {
t.Helper()
resp := postJSON(t, h.lobbyAdminURL+"/api/v1/lobby/games", map[string]any{
"game_name": name,
"game_type": "public",
"min_players": 2,
"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",
}, nil)
require.Equalf(t, http.StatusCreated, resp.StatusCode, "admin create game: %s", resp.Body)
var record map[string]any
require.NoError(t, json.Unmarshal([]byte(resp.Body), &record))
gameID, ok := record["game_id"].(string)
require.True(t, ok, "game_id missing in admin create response: %s", resp.Body)
return gameID
}
func (h *lobbyUserHarness) 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, "open enrollment: %s", resp.Body)
}
func (h *lobbyUserHarness) submitApplicationExpectStatus(t *testing.T, userID, gameID, raceName string, want int) 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, want, resp.StatusCode, "submit application: %s", resp.Body)
var body map[string]any
if resp.Body != "" {
require.NoError(t, json.Unmarshal([]byte(resp.Body), &body))
}
return body
}
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
}
func requireErrorCode(t *testing.T, body map[string]any, want string) {
t.Helper()
require.NotNil(t, body, "error response body must not be empty")
envelope, ok := body["error"].(map[string]any)
require.Truef(t, ok, "expected error envelope, got %v", body)
require.Equalf(t, want, envelope["code"], "expected error code %q, got %v", want, envelope["code"])
}