"""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