0f8f8698bd
Three fixes around the dev sandbox end-to-end path. Each one was flushed out by an actual login walkthrough after the previous commit. Backend bootstrap now treats `cancelled`, `finished`, and `start_failed` as terminal: the per-boot find-or-create skips such games and provisions a fresh one. Without this, a single bad shutdown cascade leaves the developer staring at a dead lobby tile forever (cancelled games don't transition back). Covered by TestTerminalSandboxStatus. Tools/local-dev: stop killing engine containers in `make down`. The runtime treats the disappearance of an engine as a real failure (cascading the lobby game to `cancelled`); leaving the container running across `down/up` lets the runtime reconciler re-attach on the next boot. The teardown happens only in `make clean`, where the DB is wiped anyway. Compose now also exposes :9090 (authenticated EdgeGateway listener) on the host so the Vite dev proxy can reach the Connect-Web surface, and bumps the gateway anti-abuse limits for `public_misc` so the same surface is not blanket-rejected with 413. Ui/frontend: the lobby's `My Games` cards are now clickable only for the playable statuses (`running`, `paused`, `finished`). All other statuses render as disabled buttons so a click on a draft or cancelled game no longer drops the user on a 404 — the in-game view at /games/:id/* doesn't exist before Phase 10 and never makes sense for a cancelled game. Vite proxy splits the dev targets so `/api/*` continues to talk to the REST listener and `/galaxy.gateway.v1.EdgeGateway/*` is routed to the Connect-Web listener via VITE_DEV_GRPC_PROXY_TARGET (defaults to :9090). 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 is reusable. Terminal states force
|
|
// the bootstrap to create a new game on the next boot rather than
|
|
// hand the developer a dead lobby tile.
|
|
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
|
|
}
|