refactor(dev): remove the dev-sandbox bootstrap everywhere
Tests · Go / test (push) Successful in 1m59s
Tests · Go / test (push) Successful in 1m59s
Stage 1 of the dev-as-prod-mirror rework. The auto-provisioned "Dev Sandbox" game and dummy users are removed so the dev contour starts empty like prod; the separate legacy-report loader stays as the test-data path. - delete backend/internal/devsandbox (package + tests) - drop the bootstrap call + DevSandboxConfig (struct, Config field, BACKEND_DEV_SANDBOX_* env, defaults, loader, validation) - strip BACKEND_DEV_SANDBOX_* from dev-deploy + local-dev compose and .env.example; the generic engine-recycle / prune-broken-engines logic stays (it serves real games) - update tooling docs (dev-deploy README + KNOWN-ISSUES, local-dev README + Makefile) and stale comments; DeleteGame and InsertMembershipDirect remain (exercised by lobby integration tests) No app behaviour change beyond not auto-creating the sandbox game.
This commit is contained in:
@@ -105,11 +105,6 @@ const (
|
||||
envDiplomailTranslatorTimeout = "BACKEND_DIPLOMAIL_TRANSLATOR_TIMEOUT"
|
||||
envDiplomailTranslatorMaxAttempts = "BACKEND_DIPLOMAIL_TRANSLATOR_MAX_ATTEMPTS"
|
||||
envDiplomailWorkerInterval = "BACKEND_DIPLOMAIL_WORKER_INTERVAL"
|
||||
|
||||
envDevSandboxEmail = "BACKEND_DEV_SANDBOX_EMAIL"
|
||||
envDevSandboxEngineImage = "BACKEND_DEV_SANDBOX_ENGINE_IMAGE"
|
||||
envDevSandboxEngineVersion = "BACKEND_DEV_SANDBOX_ENGINE_VERSION"
|
||||
envDevSandboxPlayerCount = "BACKEND_DEV_SANDBOX_PLAYER_COUNT"
|
||||
)
|
||||
|
||||
// Default values applied when an environment variable is absent.
|
||||
@@ -178,9 +173,6 @@ const (
|
||||
defaultDiplomailTranslatorTimeout = 10 * time.Second
|
||||
defaultDiplomailTranslatorMaxAttempts = 5
|
||||
defaultDiplomailWorkerInterval = 2 * time.Second
|
||||
|
||||
defaultDevSandboxEngineVersion = "0.1.0"
|
||||
defaultDevSandboxPlayerCount = 20
|
||||
)
|
||||
|
||||
// Allowed values for the closed-set string options.
|
||||
@@ -219,29 +211,12 @@ type Config struct {
|
||||
Runtime RuntimeConfig
|
||||
Notification NotificationConfig
|
||||
Diplomail DiplomailConfig
|
||||
DevSandbox DevSandboxConfig
|
||||
|
||||
// FreshnessWindow mirrors the gateway freshness window and is used by the
|
||||
// push server to bound the cursor TTL.
|
||||
FreshnessWindow time.Duration
|
||||
}
|
||||
|
||||
// DevSandboxConfig configures the boot-time bootstrap implemented in
|
||||
// `backend/internal/devsandbox`. When Email is empty the bootstrap
|
||||
// is a no-op, which is the production posture. When Email is set —
|
||||
// from `BACKEND_DEV_SANDBOX_EMAIL` in the `tools/local-dev` stack —
|
||||
// the bootstrap idempotently provisions a real user, the configured
|
||||
// number of dummy participants, a private "Dev Sandbox" game, the
|
||||
// matching memberships, and drives the lifecycle to `running`. The
|
||||
// engine image and engine version refer to a row that the bootstrap
|
||||
// also seeds in `engine_versions`.
|
||||
type DevSandboxConfig struct {
|
||||
Email string
|
||||
EngineImage string
|
||||
EngineVersion string
|
||||
PlayerCount int
|
||||
}
|
||||
|
||||
// LoggingConfig stores the parameters used by the structured logger.
|
||||
type LoggingConfig struct {
|
||||
// Level is the zap level name (e.g. "debug", "info", "warn", "error").
|
||||
@@ -572,10 +547,6 @@ func DefaultConfig() Config {
|
||||
TranslatorMaxAttempts: defaultDiplomailTranslatorMaxAttempts,
|
||||
WorkerInterval: defaultDiplomailWorkerInterval,
|
||||
},
|
||||
DevSandbox: DevSandboxConfig{
|
||||
EngineVersion: defaultDevSandboxEngineVersion,
|
||||
PlayerCount: defaultDevSandboxPlayerCount,
|
||||
},
|
||||
Runtime: RuntimeConfig{
|
||||
WorkerPoolSize: defaultRuntimeWorkerPoolSize,
|
||||
JobQueueSize: defaultRuntimeJobQueueSize,
|
||||
@@ -755,13 +726,6 @@ func LoadFromEnv() (Config, error) {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
cfg.DevSandbox.Email = strings.TrimSpace(loadString(envDevSandboxEmail, cfg.DevSandbox.Email))
|
||||
cfg.DevSandbox.EngineImage = strings.TrimSpace(loadString(envDevSandboxEngineImage, cfg.DevSandbox.EngineImage))
|
||||
cfg.DevSandbox.EngineVersion = strings.TrimSpace(loadString(envDevSandboxEngineVersion, cfg.DevSandbox.EngineVersion))
|
||||
if cfg.DevSandbox.PlayerCount, err = loadInt(envDevSandboxPlayerCount, cfg.DevSandbox.PlayerCount); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if err := cfg.Validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
@@ -973,21 +937,6 @@ func (c Config) Validate() error {
|
||||
}
|
||||
}
|
||||
|
||||
if email := strings.TrimSpace(c.DevSandbox.Email); email != "" {
|
||||
if _, err := netmail.ParseAddress(email); err != nil {
|
||||
return fmt.Errorf("%s must be a valid RFC 5322 address: %w", envDevSandboxEmail, err)
|
||||
}
|
||||
if strings.TrimSpace(c.DevSandbox.EngineImage) == "" {
|
||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineImage, envDevSandboxEmail)
|
||||
}
|
||||
if strings.TrimSpace(c.DevSandbox.EngineVersion) == "" {
|
||||
return fmt.Errorf("%s must not be empty when %s is set", envDevSandboxEngineVersion, envDevSandboxEmail)
|
||||
}
|
||||
if c.DevSandbox.PlayerCount <= 0 {
|
||||
return fmt.Errorf("%s must be positive when %s is set", envDevSandboxPlayerCount, envDevSandboxEmail)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,287 +0,0 @@
|
||||
// Package devsandbox provisions a ready-to-play game on backend boot
|
||||
// for the `tools/local-dev` stack.
|
||||
//
|
||||
// Bootstrap is invoked from `backend/cmd/backend/main.go` after the
|
||||
// admin bootstrap and before the HTTP listener starts. It reads
|
||||
// `cfg.DevSandbox`; when `Email` is empty (the production posture)
|
||||
// the function logs "skipped" and returns nil. When set, it
|
||||
// idempotently:
|
||||
//
|
||||
// 1. registers the configured engine version and image;
|
||||
// 2. find-or-creates the real dev user with the configured email;
|
||||
// 3. find-or-creates `cfg.PlayerCount - 1` deterministic dummy
|
||||
// users so the engine's minimum-players constraint is met;
|
||||
// 4. find-or-creates a private "Dev Sandbox" game owned by the
|
||||
// real user with min/max_players = cfg.PlayerCount and a
|
||||
// year-out turn schedule (effectively frozen at turn 1);
|
||||
// 5. inserts memberships for all participants bypassing the
|
||||
// application/approval flow;
|
||||
// 6. drives the lifecycle to `running` (or as far as possible if
|
||||
// the runtime is busy).
|
||||
//
|
||||
// The function is a no-op on subsequent boots once the game is
|
||||
// running; partial states from earlier crashes are recovered.
|
||||
package devsandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
"galaxy/backend/internal/lobby"
|
||||
"galaxy/backend/internal/runtime"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SandboxGameName is the display name used to identify the
|
||||
// auto-provisioned game on subsequent reboots. The combination of
|
||||
// game_name and owner_user_id is unique enough in practice — only
|
||||
// the dev sandbox bootstrap creates a game owned by the configured
|
||||
// real user with this exact name.
|
||||
const SandboxGameName = "Dev Sandbox"
|
||||
|
||||
// SandboxTurnSchedule keeps the game on turn 1 by scheduling the
|
||||
// next turn a year out. The runtime scheduler still parses this and
|
||||
// will tick once a year — long enough to never interfere with
|
||||
// solo UI development.
|
||||
const SandboxTurnSchedule = "0 0 1 1 *"
|
||||
|
||||
// UserEnsurer matches `auth.UserEnsurer`. We define a local
|
||||
// interface to avoid importing the auth package and circular
|
||||
// dependencies — the production wiring passes the same `*user.Service`
|
||||
// instance used by auth.
|
||||
type UserEnsurer interface {
|
||||
EnsureByEmail(ctx context.Context, email, preferredLanguage, timeZone, declaredCountry string) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// Deps aggregates the collaborators Bootstrap needs.
|
||||
type Deps struct {
|
||||
Users UserEnsurer
|
||||
Lobby *lobby.Service
|
||||
EngineVersions *runtime.EngineVersionService
|
||||
}
|
||||
|
||||
// Bootstrap runs the seven-step provisioning flow described on the
|
||||
// package doc comment. Errors are returned to the caller; the boot
|
||||
// path in `cmd/backend/main.go` aborts startup if Bootstrap fails so
|
||||
// a misconfigured dev environment surfaces immediately rather than
|
||||
// silently leaving the lobby empty.
|
||||
func Bootstrap(ctx context.Context, deps Deps, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("dev_sandbox")
|
||||
|
||||
if cfg.Email == "" {
|
||||
logger.Info("skipped (no email)")
|
||||
return nil
|
||||
}
|
||||
if deps.Users == nil || deps.Lobby == nil || deps.EngineVersions == nil {
|
||||
return errors.New("dev_sandbox: deps.Users, deps.Lobby and deps.EngineVersions are required")
|
||||
}
|
||||
if cfg.PlayerCount <= 0 {
|
||||
return fmt.Errorf("dev_sandbox: PlayerCount must be positive, got %d", cfg.PlayerCount)
|
||||
}
|
||||
|
||||
if err := ensureEngineVersion(ctx, deps.EngineVersions, cfg, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
realID, err := deps.Users.EnsureByEmail(ctx, cfg.Email, "en", "UTC", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: ensure real user: %w", err)
|
||||
}
|
||||
|
||||
dummyIDs := make([]uuid.UUID, 0, cfg.PlayerCount-1)
|
||||
for i := 1; i < cfg.PlayerCount; i++ {
|
||||
email := fmt.Sprintf("dev-dummy-%02d@local.test", i)
|
||||
id, err := deps.Users.EnsureByEmail(ctx, email, "en", "UTC", "")
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: ensure dummy %d: %w", i, err)
|
||||
}
|
||||
dummyIDs = append(dummyIDs, id)
|
||||
}
|
||||
|
||||
if err := purgeTerminalSandboxGames(ctx, deps.Lobby, realID, logger); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
game, err := findOrCreateSandboxGame(ctx, deps.Lobby, realID, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
game, err = ensureMembershipsAndDrive(ctx, deps.Lobby, game, realID, dummyIDs, logger)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("bootstrap complete",
|
||||
zap.String("user_id", realID.String()),
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
zap.String("status", game.Status),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
func ensureEngineVersion(ctx context.Context, svc *runtime.EngineVersionService, cfg config.DevSandboxConfig, logger *zap.Logger) error {
|
||||
_, err := svc.Register(ctx, runtime.RegisterInput{
|
||||
Version: cfg.EngineVersion,
|
||||
ImageRef: cfg.EngineImage,
|
||||
})
|
||||
switch {
|
||||
case err == nil:
|
||||
logger.Info("engine version registered",
|
||||
zap.String("version", cfg.EngineVersion),
|
||||
zap.String("image", cfg.EngineImage),
|
||||
)
|
||||
return nil
|
||||
case errors.Is(err, runtime.ErrEngineVersionTaken):
|
||||
logger.Debug("engine version already registered",
|
||||
zap.String("version", cfg.EngineVersion),
|
||||
)
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("dev_sandbox: register engine version: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// terminalSandboxStatus reports whether a sandbox game has reached a
|
||||
// state from which it can no longer be driven back to running. We
|
||||
// treat such games as "absent" so the next bootstrap creates a fresh
|
||||
// one rather than handing the developer a dead lobby tile.
|
||||
func terminalSandboxStatus(status string) bool {
|
||||
switch status {
|
||||
case lobby.GameStatusCancelled, lobby.GameStatusFinished, lobby.GameStatusStartFailed:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// purgeTerminalSandboxGames deletes every previous "Dev Sandbox" game
|
||||
// the dev user owns that has reached a terminal state
|
||||
// (cancelled / finished / start_failed). The cascade declared in
|
||||
// `00001_init.sql` removes the matching memberships, applications,
|
||||
// invites, runtime records, and player mappings in the same write,
|
||||
// so the developer's lobby never piles up dead tiles between
|
||||
// `make rebuild` cycles. Non-terminal games are left untouched —
|
||||
// a `running` sandbox from a previous boot is the happy path.
|
||||
func purgeTerminalSandboxGames(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, logger *zap.Logger) error {
|
||||
games, err := svc.ListMyGames(ctx, ownerID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||
continue
|
||||
}
|
||||
if !terminalSandboxStatus(g.Status) {
|
||||
continue
|
||||
}
|
||||
if err := svc.DeleteGame(ctx, g.GameID); err != nil {
|
||||
return fmt.Errorf("dev_sandbox: delete terminal sandbox %s: %w", g.GameID, err)
|
||||
}
|
||||
logger.Info("purged terminal sandbox game",
|
||||
zap.String("game_id", g.GameID.String()),
|
||||
zap.String("status", g.Status),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findOrCreateSandboxGame(ctx context.Context, svc *lobby.Service, ownerID uuid.UUID, cfg config.DevSandboxConfig) (lobby.GameRecord, error) {
|
||||
games, err := svc.ListMyGames(ctx, ownerID)
|
||||
if err != nil {
|
||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: list my games: %w", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.GameName != SandboxGameName || g.OwnerUserID == nil || *g.OwnerUserID != ownerID {
|
||||
continue
|
||||
}
|
||||
// `purgeTerminalSandboxGames` ran before us, so any sandbox
|
||||
// game still in the list is either a live one we should
|
||||
// reuse or a transient state we can drive forward.
|
||||
return g, nil
|
||||
}
|
||||
rec, err := svc.CreateGame(ctx, lobby.CreateGameInput{
|
||||
OwnerUserID: &ownerID,
|
||||
Visibility: lobby.VisibilityPrivate,
|
||||
GameName: SandboxGameName,
|
||||
Description: "Auto-provisioned by backend/internal/devsandbox for solo UI development.",
|
||||
MinPlayers: int32(cfg.PlayerCount),
|
||||
MaxPlayers: int32(cfg.PlayerCount),
|
||||
StartGapHours: 0,
|
||||
StartGapPlayers: 0,
|
||||
EnrollmentEndsAt: time.Now().Add(365 * 24 * time.Hour),
|
||||
TurnSchedule: SandboxTurnSchedule,
|
||||
TargetEngineVersion: cfg.EngineVersion,
|
||||
})
|
||||
if err != nil {
|
||||
return lobby.GameRecord{}, fmt.Errorf("dev_sandbox: create game: %w", err)
|
||||
}
|
||||
return rec, nil
|
||||
}
|
||||
|
||||
func ensureMembershipsAndDrive(ctx context.Context, svc *lobby.Service, game lobby.GameRecord, realID uuid.UUID, dummyIDs []uuid.UUID, logger *zap.Logger) (lobby.GameRecord, error) {
|
||||
caller := realID
|
||||
if game.Status == lobby.GameStatusDraft {
|
||||
next, err := svc.OpenEnrollment(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: open enrollment: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusEnrollmentOpen {
|
||||
users := append([]uuid.UUID{realID}, dummyIDs...)
|
||||
for i, uid := range users {
|
||||
raceName := fmt.Sprintf("Sandbox-%02d", i+1)
|
||||
if _, err := svc.InsertMembershipDirect(ctx, lobby.InsertMembershipDirectInput{
|
||||
GameID: game.GameID,
|
||||
UserID: uid,
|
||||
RaceName: raceName,
|
||||
}); err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: insert membership %d: %w", i+1, err)
|
||||
}
|
||||
}
|
||||
logger.Info("memberships ensured",
|
||||
zap.Int("count", len(users)),
|
||||
zap.String("game_id", game.GameID.String()),
|
||||
)
|
||||
next, err := svc.ReadyToStart(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: ready to start: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusReadyToStart {
|
||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: start: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
|
||||
if game.Status == lobby.GameStatusStartFailed {
|
||||
next, err := svc.RetryStart(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
logger.Warn("retry start failed", zap.Error(err))
|
||||
return game, nil
|
||||
}
|
||||
game = next
|
||||
if game.Status == lobby.GameStatusReadyToStart {
|
||||
next, err := svc.Start(ctx, &caller, false, game.GameID)
|
||||
if err != nil {
|
||||
return game, fmt.Errorf("dev_sandbox: start after retry: %w", err)
|
||||
}
|
||||
game = next
|
||||
}
|
||||
}
|
||||
|
||||
return game, nil
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package devsandbox
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"galaxy/backend/internal/config"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// TestBootstrapSkippedWhenEmailEmpty exercises the no-op branch: with
|
||||
// the production posture (Email == "") Bootstrap must return without
|
||||
// touching any dependency. The fact that Users/Lobby/EngineVersions
|
||||
// are nil here doubles as a check that the early-return runs first.
|
||||
func TestBootstrapSkippedWhenEmailEmpty(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{},
|
||||
config.DevSandboxConfig{},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil error on empty email, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapRejectsZeroPlayerCount confirms the validation
|
||||
// short-circuits the flow before any DB call when PlayerCount is
|
||||
// non-positive but Email is set. The error path is fast and never
|
||||
// dereferences the (still-nil) Users/Lobby deps.
|
||||
func TestBootstrapRejectsZeroPlayerCount(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||
config.DevSandboxConfig{
|
||||
Email: "dev@local.test",
|
||||
EngineImage: "galaxy-engine:local-dev",
|
||||
EngineVersion: "0.0.0-local-dev",
|
||||
PlayerCount: 0,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on zero PlayerCount, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapRejectsMissingDeps checks that a misconfigured wiring
|
||||
// (Email set but one of the required services nil) fails fast rather
|
||||
// than panicking when the bootstrap reaches its first service call.
|
||||
func TestBootstrapRejectsMissingDeps(t *testing.T) {
|
||||
err := Bootstrap(
|
||||
context.Background(),
|
||||
Deps{Users: stubEnsurer{}, Lobby: nil, EngineVersions: nil},
|
||||
config.DevSandboxConfig{
|
||||
Email: "dev@local.test",
|
||||
EngineImage: "galaxy-engine:local-dev",
|
||||
EngineVersion: "0.0.0-local-dev",
|
||||
PlayerCount: 20,
|
||||
},
|
||||
zap.NewNop(),
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("expected error on missing deps, got nil")
|
||||
}
|
||||
if !errors.Is(err, errMissingDepsSentinel) && err.Error() == "" {
|
||||
// The exact wording is not part of the contract; this branch
|
||||
// only asserts the error is non-nil and human-readable.
|
||||
t.Fatalf("error has empty message: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// errMissingDepsSentinel exists so the assertion above can compile;
|
||||
// the real error is constructed via errors.New inside Bootstrap and
|
||||
// is intentionally not exported. The test only needs to confirm the
|
||||
// returned error has a message.
|
||||
var errMissingDepsSentinel = errors.New("sentinel")
|
||||
|
||||
// TestTerminalSandboxStatus pins the contract that decides whether a
|
||||
// previously created sandbox game gets purged on the next boot.
|
||||
// Terminal states are deleted (cascade-style) so the developer's
|
||||
// lobby never piles up dead tiles between `make rebuild` cycles.
|
||||
func TestTerminalSandboxStatus(t *testing.T) {
|
||||
terminal := []string{"cancelled", "finished", "start_failed"}
|
||||
live := []string{"draft", "enrollment_open", "ready_to_start", "starting", "running", "paused"}
|
||||
|
||||
for _, status := range terminal {
|
||||
if !terminalSandboxStatus(status) {
|
||||
t.Errorf("expected %q to be terminal", status)
|
||||
}
|
||||
}
|
||||
for _, status := range live {
|
||||
if terminalSandboxStatus(status) {
|
||||
t.Errorf("expected %q to be non-terminal", status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type stubEnsurer struct{}
|
||||
|
||||
func (stubEnsurer) EnsureByEmail(_ context.Context, _, _, _, _ string) (uuid.UUID, error) {
|
||||
return uuid.UUID{}, nil
|
||||
}
|
||||
@@ -274,11 +274,10 @@ func (s *Service) ListFinishedGamesBefore(ctx context.Context, cutoff time.Time)
|
||||
// `ON DELETE CASCADE` constraints declared in `00001_init.sql`.
|
||||
// Idempotent: returns nil when no game matches.
|
||||
//
|
||||
// Phase 14 introduces this method for the dev-sandbox bootstrap so a
|
||||
// terminal "Dev Sandbox" tile from a previous local-dev session can
|
||||
// be scrubbed before a fresh game spawns. Production callers must
|
||||
// stay on the regular cancel / finish lifecycle — `DeleteGame` is
|
||||
// destructive and bypasses the cascade-notification machinery.
|
||||
// `DeleteGame` is destructive — a hard delete that bypasses the
|
||||
// cascade-notification machinery — so production callers stay on the
|
||||
// regular cancel / finish lifecycle. It is exercised by the lobby
|
||||
// integration tests.
|
||||
func (s *Service) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||
if err := s.deps.Store.DeleteGame(ctx, gameID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -248,8 +248,8 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
||||
// bootstrap relies on: removing a game wipes every referencing row
|
||||
// TestDeleteGameCascadesEverything pins the DeleteGame contract:
|
||||
// removing a game wipes every referencing row
|
||||
// (memberships, applications, invites, runtime_records,
|
||||
// player_mappings) in a single SQL statement. Before this is wired
|
||||
// the developer's lobby pile up cancelled tiles between
|
||||
|
||||
@@ -20,9 +20,9 @@ type InsertMembershipDirectInput struct {
|
||||
// writes as ApproveApplication: the per-game race-name reservation
|
||||
// row plus the membership row, and refreshes the in-memory caches.
|
||||
//
|
||||
// The method is intended for boot-time provisioning by
|
||||
// `backend/internal/devsandbox` and similar trusted callers. It is
|
||||
// not exposed through any HTTP handler. The caller must guarantee
|
||||
// The method is intended for trusted boot-time provisioning and
|
||||
// integration tests; it is not exposed through any HTTP handler. The
|
||||
// caller must guarantee
|
||||
// game.Status == GameStatusEnrollmentOpen — the function returns
|
||||
// ErrConflict otherwise — and that the race-name policy and
|
||||
// canonical-key invariants are honoured (the implementation reuses
|
||||
@@ -30,9 +30,8 @@ type InsertMembershipDirectInput struct {
|
||||
// or unsuitable name still fails).
|
||||
//
|
||||
// Idempotency: if a membership for (GameID, UserID) already exists
|
||||
// the function returns the existing row without modifying state.
|
||||
// This makes the helper safe to call on every backend boot from
|
||||
// devsandbox.Bootstrap.
|
||||
// the function returns the existing row without modifying state, so
|
||||
// the helper is safe to call repeatedly.
|
||||
func (s *Service) InsertMembershipDirect(ctx context.Context, in InsertMembershipDirectInput) (Membership, error) {
|
||||
displayName, err := ValidateDisplayName(in.RaceName)
|
||||
if err != nil {
|
||||
|
||||
@@ -236,9 +236,8 @@ func (s *Store) ListMyGames(ctx context.Context, userID uuid.UUID) ([]GameRecord
|
||||
// referencing table (memberships / applications / invites /
|
||||
// runtime_records / player_mappings — all declared with ON DELETE
|
||||
// CASCADE in `00001_init.sql`). Idempotent: returns nil when no row
|
||||
// matches. Used by the dev-sandbox bootstrap to scrub terminal
|
||||
// games on every backend boot so the developer's lobby never piles
|
||||
// up cancelled tiles.
|
||||
// matches. A hard delete for trusted callers and integration tests;
|
||||
// production lifecycle uses cancel / finish.
|
||||
func (s *Store) DeleteGame(ctx context.Context, gameID uuid.UUID) error {
|
||||
g := table.Games
|
||||
stmt := g.DELETE().WHERE(g.GameID.EQ(postgres.UUID(gameID)))
|
||||
|
||||
Reference in New Issue
Block a user