local-dev: auto-purge terminal Dev Sandbox games on every boot
Previously a cancelled / finished / start_failed sandbox game would hang in the dev user's lobby until manually cleaned up — `make up` would create a new running game alongside it but the dead tiles piled up. Now backend's `devsandbox.Bootstrap` deletes every terminal sandbox game owned by the dev user before find-or-create runs, so the lobby always shows exactly one running tile. Schema: `runtime_records` and `player_mappings` gain `ON DELETE CASCADE` on their `game_id` foreign keys so a single `DELETE FROM games` cleans every referencing row in one write. Pre-prod migration rule applies — change goes into `00001_init.sql`, not a new migration. API: `lobby.Service.DeleteGame` is the new destructive helper that backs the bootstrap purge. It bypasses the cancel-cascade-notify pipeline; production callers must stay on the regular lifecycle. The dev-sandbox docs in `tools/local-dev/README.md` spell out the new behaviour. Tests: - backend/internal/lobby/lobby_e2e_test.go gains `TestDeleteGameCascadesEverything` proving CASCADE works end-to-end against a real Postgres testcontainer. - backend/internal/devsandbox keeps its existing terminal-status contract test; the new `purgeTerminalSandboxGames` helper rides on the same `terminalSandboxStatus` predicate. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -244,6 +244,70 @@ func TestEndToEndPrivateGameFlow(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeleteGameCascadesEverything pins the contract the dev-sandbox
|
||||
// bootstrap relies on: 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
|
||||
// `make rebuild` cycles; with it, every boot starts from a clean
|
||||
// slate.
|
||||
func TestDeleteGameCascadesEverything(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
clock := func() time.Time { return now }
|
||||
svc := newServiceForTest(t, db, clock, 5)
|
||||
|
||||
owner := uuid.New()
|
||||
seedAccount(t, db, owner)
|
||||
game, err := svc.CreateGame(context.Background(), lobby.CreateGameInput{
|
||||
OwnerUserID: &owner,
|
||||
Visibility: lobby.VisibilityPrivate,
|
||||
GameName: "Doomed",
|
||||
MinPlayers: 1,
|
||||
MaxPlayers: 4,
|
||||
StartGapHours: 1,
|
||||
StartGapPlayers: 1,
|
||||
EnrollmentEndsAt: now.Add(time.Hour),
|
||||
TurnSchedule: "0 0 * * *",
|
||||
TargetEngineVersion: "1.0.0",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create game: %v", err)
|
||||
}
|
||||
if _, err := svc.OpenEnrollment(context.Background(), &owner, false, game.GameID); err != nil {
|
||||
t.Fatalf("open enrollment: %v", err)
|
||||
}
|
||||
if _, err := svc.InsertMembershipDirect(context.Background(), lobby.InsertMembershipDirectInput{
|
||||
GameID: game.GameID,
|
||||
UserID: owner,
|
||||
RaceName: "Owner",
|
||||
}); err != nil {
|
||||
t.Fatalf("insert membership: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
|
||||
t.Fatalf("delete game: %v", err)
|
||||
}
|
||||
|
||||
// Verify cascade: the game must be gone, ListMyGames must drop
|
||||
// it, and re-deleting the same id is a no-op.
|
||||
if _, err := svc.GetGame(context.Background(), game.GameID); !errors.Is(err, lobby.ErrNotFound) {
|
||||
t.Fatalf("get after delete: err = %v, want ErrNotFound", err)
|
||||
}
|
||||
games, err := svc.ListMyGames(context.Background(), owner)
|
||||
if err != nil {
|
||||
t.Fatalf("list my games: %v", err)
|
||||
}
|
||||
for _, g := range games {
|
||||
if g.GameID == game.GameID {
|
||||
t.Fatalf("ListMyGames still lists the deleted game")
|
||||
}
|
||||
}
|
||||
if err := svc.DeleteGame(context.Background(), game.GameID); err != nil {
|
||||
t.Fatalf("delete idempotent: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndToEndPublicGameApplicationApproval(t *testing.T) {
|
||||
db := startPostgres(t)
|
||||
now := time.Now().UTC()
|
||||
|
||||
Reference in New Issue
Block a user