Files
ducoterra f51ba919af phase: 05_anonymous_lobbies_and_room_lifecycle
All green. Final report:

**Phase 05 final verification pass — complete, 100% green.**

**Verified (all 5 tasks already in `complete/`):**
- `RoomSupervisor` import, `POST /api/rooms` → 5-char Crockford code (`VBE8Y`), `GET /api/rooms/{code}` → seat list, join/session/404 flows — all live over HTTP
- `docs/ROOMS.md` present with FSM diagram, crash-isolation and "What is lost on restart" sections
- Phase tests all pass: lifecycle 4-seat `WON`→`LOBBY` (7 tests), crash isolation, GC/idle via `FakeClock` (no sleeps), FSM, tick cadence, supervisor, sessions

**Results:**
- `uv run pytest` → **1506 passed, 0 failed**; coverage **98.89%** (>90% floor; `room.py` 99%, `rooms.py` 97%, `sessions.py` 99% — all ≥95% per-module gate)
- `uv run ruff check .` → clean; `uv run pyright` → 0 errors; `uv run undercooked-levels validate` → content OK; server boots, `/healthz` 200, client at `/` 200
- e2e US-01 suite re-run after fixes: 10 passed

**Documentation — inconsistencies found and fixed:**
- `README.md`: status now lists phases 01–05 complete, next pending `06_realtime_websocket_protocol`; dropped "(phase 05, in progress)"; 3 stale "Not implemented until phase 05." references corrected
- `app/static/js/app.js` + `tests/e2e/test_us01_landing_surface.py`: placeholder string updated in lockstep to "Not implemented until phase 07." (phase 07 task 02 wires the buttons), comments updated to reflect phase-05 completion
- `AGENTS.md`: reviewed in full — §6 already documents all phase-05 modules accurately; no changes needed

**Decisions:** no code behavior changes beyond the user-visible placeholder string; no live-infrastructure touch (only a local loopback server, stopped).

**Next pending phase:** `06_realtime_websocket_protocol`.
2026-10-08 05:17:21 -04:00

283 lines
11 KiB
Python

"""Integration: the room HTTP surface (`routes_rooms.py`) over a live
in-process app.
Covers the three endpoints — `POST /api/rooms` (201 + the once-returned
server-minted token), `GET /api/rooms/{code}`, `POST /api/rooms/{code}/join` —
plus the named refusals (`room_full` 503 for a full room *and* a full
process, `room_closed` 410, `room_not_found` 404 for a malformed *or*
unknown code: the two must stay indistinguishable, US-02 criterion 4),
no-token-leakage for other players, name sanitization at the boundary, and
`/healthz`'s live room count. Time-dependent tests swap the app's
supervisor for one on a `FakeClock` *before* the TestClient's lifespan runs.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from undercooked.config import Settings
from undercooked.server.app import create_app
from undercooked.server.clock import FakeClock
from undercooked.server.codes import CROCKFORD
from undercooked.server.rooms import RoomSupervisor
from undercooked.server.sessions import new_session
pytestmark = pytest.mark.integration
#: The test-isolated content dir (a recipe book, no levels) — explicit, so
#: the app never reads the shipped `levels/` implicitly.
FIXTURE_LEVELS_DIR = Path(__file__).resolve().parents[1] / "fixtures" / "levels"
def make_app(**settings_overrides: Any) -> FastAPI:
settings = Settings(levels_dir=FIXTURE_LEVELS_DIR, **settings_overrides)
return create_app(settings)
class FakeClockSupervisorApp:
"""An app whose supervisor runs on a `FakeClock`.
Built by swapping `app.state.supervisor` *before* the TestClient's
lifespan runs `supervisor.start()`; the lifespan then starts (and later
shuts down) the fake-clock supervisor, so GC is drivable without sleeps.
"""
def __init__(self, **settings_overrides: Any) -> None:
self.app = make_app(**settings_overrides)
self.clock = FakeClock()
self.app.state.supervisor = RoomSupervisor(
self.app.state.settings, self.clock, self.app.state.content
)
def create_room(client: TestClient, name: str | None = None) -> dict[str, Any]:
response = client.post("/api/rooms", json={"name": name} if name is not None else {})
assert response.status_code == 201, response.text
return response.json()
def join_room(
client: TestClient, code: str, token: str, name: str | None = None
) -> tuple[int, dict[str, Any]]:
body: dict[str, Any] = {"token": token}
if name is not None:
body["name"] = name
response = client.post(f"/api/rooms/{code}/join", json=body)
return response.status_code, response.json()
# ---------------------------------------------------------------------- create
def test_create_room_returns_201_with_a_crockford_code_seats_and_you() -> None:
with TestClient(make_app()) as client:
body = create_room(client, "Ada")
assert len(body["room_code"]) == 5
assert set(body["room_code"]) <= set(CROCKFORD)
assert body["state"] == "lobby"
assert body["seats"] == [
{"index": 0, "name": "Ada", "host": True, "connected": True}
]
assert len(body["you"]["token"]) == 32 # token_urlsafe(24): 24 bytes, base64url
assert body["you"]["seat"] == 0
assert body["you"]["host"] is True
def test_create_room_with_no_name_defaults_to_cook_1() -> None:
with TestClient(make_app()) as client:
body = create_room(client)
assert body["seats"][0]["name"] == "Cook #1"
def test_create_room_sanitizes_the_name_at_the_boundary() -> None:
with TestClient(make_app()) as client:
body = client.post(
"/api/rooms", json={"name": " A<>d\x01a B\n"}
).json()
assert body["seats"][0]["name"] == "Ada B"
with TestClient(make_app()) as client:
body = client.post(
"/api/rooms", json={"name": "A" * 40}
).json()
assert body["seats"][0]["name"] == "A" * 16 # the 16-char cap
def test_create_room_without_any_body_is_the_bare_curl_criterion() -> None:
"""The phase's completion criterion: `curl -X POST /api/rooms`
(no JSON body at all) must not 422 — it reads as no name, no
token (a server-minted token, the `Cook #1` default)."""
with TestClient(make_app()) as client:
response = client.post("/api/rooms")
assert response.status_code == 201, response.text
body = response.json()
assert body["seats"] == [
{"index": 0, "name": "Cook #1", "host": True, "connected": True}
]
assert len(body["you"]["token"]) == 32
def test_create_room_at_process_capacity_is_503_room_full() -> None:
with TestClient(make_app(max_rooms=1)) as client:
assert create_room(client)["room_code"]
response = client.post("/api/rooms", json={})
assert response.status_code == 503
assert response.json() == {"code": "room_full"}
def test_create_room_rejects_malformed_bodies() -> None:
with TestClient(make_app()) as client:
assert client.post("/api/rooms", json={"name": "Ada", "extra": 1}).status_code == 422
assert client.post("/api/rooms", json={"n": "Ada"}).status_code == 422
# ------------------------------------------------------------------------ get
def test_get_room_shows_seats_without_any_tokens() -> None:
with TestClient(make_app()) as client:
code = create_room(client, "Ada")["room_code"]
body = client.get(f"/api/rooms/{code.lower()}").json() # normalized
assert body["room_code"] == code
assert body["state"] == "lobby"
assert len(body["seats"]) == 1
assert set(body["seats"][0]) == {"index", "name", "host", "connected"}
assert "token" not in body["seats"][0]
assert body["you"] is None # no caller identity exists in a GET
def test_get_unknown_and_malformed_codes_are_indistinguishable() -> None:
"""US-02 criterion 4: `ZZZZZ` (unknown) and `!!!` (malformed) must
produce the *same* response, so the API is not a room-existence oracle."""
with TestClient(make_app()) as client:
unknown = client.get("/api/rooms/ZZZZZ")
malformed = client.get("/api/rooms/!!!")
assert unknown.status_code == 404
assert malformed.status_code == 404
assert unknown.json() == malformed.json() == {"code": "room_not_found"}
# ----------------------------------------------------------------------- join
def test_join_seats_the_player_in_the_next_seat_and_hides_other_tokens() -> None:
with TestClient(make_app()) as client:
code = create_room(client, "Ada")["room_code"]
status, body = join_room(client, code, "tok-bob", "Bob")
assert status == 200
assert body["you"] == {"token": "tok-bob", "seat": 1, "host": False}
assert [seat["name"] for seat in body["seats"]] == ["Ada", "Bob"]
for seat in body["seats"]:
assert "token" not in seat
blob = str(body["seats"])
assert "tok-bob" not in blob # never in the *other* players' views
def test_join_rejoin_is_idempotent_and_keeps_the_seat() -> None:
with TestClient(make_app()) as client:
code = create_room(client, "Ada")["room_code"]
status_first, body_first = join_room(client, code, "tok-bob", "Bob")
status_second, body_second = join_room(client, code, "tok-bob")
assert status_first == 200 and status_second == 200
assert body_second["you"]["seat"] == body_first["you"]["seat"] == 1
assert len(body_second["seats"]) == 2 # no second seat was minted
def test_join_updates_the_name_only_when_one_is_typed() -> None:
with TestClient(make_app()) as client:
code = create_room(client, "Ada")["room_code"]
join_room(client, code, "tok-bob", "Bob")
status, body = join_room(client, code, "tok-bob", "Robert")
assert status == 200
assert body["seats"][1]["name"] == "Robert"
status, body = join_room(client, code, "tok-bob")
assert status == 200
assert body["seats"][1]["name"] == "Robert" # untouched on rejoin
def test_join_a_full_room_is_503_room_full() -> None:
with TestClient(make_app(max_players=1)) as client:
code = create_room(client, "Ada")["room_code"]
status, body = join_room(client, code, "tok-bob", "Bob")
assert status == 503
assert body == {"code": "room_full"}
def test_join_a_closed_room_is_410_room_closed() -> None:
"""Close the room *before* the app starts (no tick task exists yet, so
the close happens in this test's own loop — never across the TestClient's
portal loop), then watch the API report the closed room until the GC
pass would drop it."""
fixture = FakeClockSupervisorApp()
session = new_session("Ada", fixture.clock)
room = fixture.app.state.supervisor.create_room(session, "Ada")
asyncio.run(fixture.app.state.supervisor.close_room(room.code))
with TestClient(fixture.app) as client:
inspect = client.get(f"/api/rooms/{room.code}")
assert inspect.status_code == 200 # still in the registry
assert inspect.json()["state"] == "closed"
status, body = join_room(client, room.code, "tok-bob", "Bob")
assert status == 410
assert body == {"code": "room_closed"}
with TestClient(fixture.app) as client: # after shutdown the room is gone
assert client.get(f"/api/rooms/{room.code}").status_code == 404
def test_join_unknown_and_malformed_codes_are_indistinguishable() -> None:
with TestClient(make_app()) as client:
unknown = client.post("/api/rooms/ZZZZZ/join", json={"token": "tok"})
malformed = client.post("/api/rooms/!!!/join", json={"token": "tok"})
assert unknown.status_code == 404
assert malformed.status_code == 404
assert unknown.json() == malformed.json() == {"code": "room_not_found"}
def test_join_rejects_malformed_bodies() -> None:
with TestClient(make_app()) as client:
code = create_room(client)["room_code"]
assert client.post(f"/api/rooms/{code}/join", json={"n": "tok"}).status_code == 422
assert (
client.post(f"/api/rooms/{code}/join", json={"token": ""}).status_code == 422
)
assert (
client.post(
f"/api/rooms/{code}/join", json={"token": "tok", "extra": 1}
).status_code
== 422
)
def test_join_moves_a_token_between_rooms() -> None:
with TestClient(make_app()) as client:
code_a = create_room(client, "Ada")["room_code"]
code_b = create_room(client, "Bob")["room_code"]
status, _ = join_room(client, code_a, "tok-ada2", "Ada2")
assert status == 200
status, body = join_room(client, code_b, "tok-ada2")
assert status == 200
assert body["you"]["seat"] == 1 # seated in room B
room_a = client.get(f"/api/rooms/{code_a}").json()
assert len(room_a["seats"]) == 1 # the token was evicted from room A
def test_healthz_reports_the_live_room_count() -> None:
with TestClient(make_app()) as client:
assert client.get("/healthz").json()["rooms"] == 0
create_room(client, "Ada")
create_room(client, "Bob")
body = client.get("/healthz").json()
assert body["status"] == "ok"
assert body["rooms"] == 2