199 lines
6.9 KiB
Go
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)
|
|
}
|