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`.
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user