6d6a384bee
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>
107 lines
3.3 KiB
Go
107 lines
3.3 KiB
Go
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
|
|
}
|