Files
galaxy-game/integration/lobbynotification/race_name_intents_test.go
T
2026-04-25 23:20:55 +02:00

199 lines
6.9 KiB
Go

// 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)
}