diff --git a/level_6_api/missions/01_first_fastapi_app/check.py b/level_6_api/missions/01_first_fastapi_app/check.py index e220403..68bdea8 100644 --- a/level_6_api/missions/01_first_fastapi_app/check.py +++ b/level_6_api/missions/01_first_fastapi_app/check.py @@ -57,6 +57,9 @@ def update_progress(mission_id: str) -> None: print("✓ GET /health → 200 with 'status' field") # ── GET /monsters ───────────────────────────────────────────────────────────── +# +# Not just "non-empty" — the names must actually match data/monsters.json. +# A hardcoded list that never calls the service would pass a length-only check. r = client.get("/monsters") if r.status_code != 200: @@ -66,7 +69,20 @@ def update_progress(mission_id: str) -> None: if not isinstance(data, list) or len(data) == 0: print("❌ GET /monsters must return a non-empty list") raise SystemExit(1) -print(f"✓ GET /monsters → 200, {len(data)} monsters") +if not all(isinstance(item, str) for item in data): + print(f"❌ GET /monsters must return a list of strings, got {data!r}") + raise SystemExit(1) + +real_data = json.loads((Path(__file__).parent / "data" / "monsters.json").read_text()) +expected_names = {m["name"] for m in real_data["monsters"]} +returned_names = set(data) +if returned_names != expected_names: + print(f"❌ GET /monsters returned {sorted(returned_names)}, " + f"but data/monsters.json actually has {sorted(expected_names)}") + print(" Return the real monster names from _service.get_available_monsters(), " + "not a hardcoded list.") + raise SystemExit(1) +print(f"✓ GET /monsters → 200, {len(data)} monsters matching data/monsters.json") update_progress("01_first_fastapi_app") print("\n✅ Mission 01 complete!") diff --git a/level_6_api/missions/05_dependencies_and_repositories/check.py b/level_6_api/missions/05_dependencies_and_repositories/check.py index 006172e..942526e 100644 --- a/level_6_api/missions/05_dependencies_and_repositories/check.py +++ b/level_6_api/missions/05_dependencies_and_repositories/check.py @@ -2,6 +2,7 @@ """Check: Mission 05 — Dependencies and Repositories.""" import json import sys +import tempfile from pathlib import Path sys.path.insert(0, str(Path(__file__).parent)) @@ -103,37 +104,52 @@ def update_progress(mission_id: str) -> None: raise SystemExit(1) print("✓ POST /battle/simulate → 200") -# ── POST /sessions ──────────────────────────────────────────────────────────── +# ── session endpoints, isolated the same way the mission teaches ───────────── +# +# The real get_session_repo() writes to data/sessions/ — calling it directly +# here would leave a real, untracked session file behind every time this +# checker runs. Override it with a temp-directory-backed repo instead, exactly +# like app.dependency_overrides in Mission 07's test fixture. -r = client.post( - "/sessions", - json={"hero_name": "Ada", "hero_class": "warrior", "monster_name": "Goblin"}, -) -if r.status_code != 201: - print(f"❌ POST /sessions returned {r.status_code} (expected 201): {r.text[:200]}") - raise SystemExit(1) -if "session_id" not in r.json(): - print("❌ POST /sessions response missing 'session_id'") +try: + from task.dependencies import get_session_repo # type: ignore[import] + from task.rpg.repositories import SessionRepository # type: ignore[import] +except ImportError as exc: + print(f"❌ Cannot import get_session_repo / SessionRepository: {exc}") raise SystemExit(1) -session_id = r.json()["session_id"] -print(f"✓ POST /sessions → 201, session_id={session_id[:8]}...") -# ── GET /sessions/{session_id} ──────────────────────────────────────────────── +_tmp_sessions = Path(tempfile.mkdtemp()) +app.dependency_overrides[get_session_repo] = lambda: SessionRepository(_tmp_sessions) +try: + r = client.post( + "/sessions", + json={"hero_name": "Ada", "hero_class": "warrior", "monster_name": "Goblin"}, + ) + if r.status_code != 201: + print(f"❌ POST /sessions returned {r.status_code} (expected 201): {r.text[:200]}") + raise SystemExit(1) + if "session_id" not in r.json(): + print("❌ POST /sessions response missing 'session_id'") + raise SystemExit(1) + session_id = r.json()["session_id"] + print(f"✓ POST /sessions → 201, session_id={session_id[:8]}...") -r = client.get(f"/sessions/{session_id}") -if r.status_code != 200: - print(f"❌ GET /sessions/{'{id}'} returned {r.status_code}: {r.text[:200]}") - raise SystemExit(1) -if "winner" not in r.json(): - print("❌ GET /sessions/{id} response missing 'winner' field") - raise SystemExit(1) -print("✓ GET /sessions/{id} → 200") + r = client.get(f"/sessions/{session_id}") + if r.status_code != 200: + print(f"❌ GET /sessions/{'{id}'} returned {r.status_code}: {r.text[:200]}") + raise SystemExit(1) + if "winner" not in r.json(): + print("❌ GET /sessions/{id} response missing 'winner' field") + raise SystemExit(1) + print("✓ GET /sessions/{id} → 200") -r = client.get("/sessions/nonexistent-id-12345") -if r.status_code != 404: - print(f"❌ GET /sessions/nonexistent should return 404, got {r.status_code}") - raise SystemExit(1) -print("✓ GET /sessions/nonexistent → 404") + r = client.get("/sessions/nonexistent-id-12345") + if r.status_code != 404: + print(f"❌ GET /sessions/nonexistent should return 404, got {r.status_code}") + raise SystemExit(1) + print("✓ GET /sessions/nonexistent → 404") +finally: + app.dependency_overrides.clear() update_progress("05_dependencies_and_repositories") print("\n✅ Mission 05 complete!") diff --git a/level_6_api/missions/06_api_errors_and_status_codes/README.md b/level_6_api/missions/06_api_errors_and_status_codes/README.md index 1e6a5ab..07e123c 100644 --- a/level_6_api/missions/06_api_errors_and_status_codes/README.md +++ b/level_6_api/missions/06_api_errors_and_status_codes/README.md @@ -99,7 +99,7 @@ Try it: ## Add It to the Game -The scaffold has four tasks for you: +The scaffold has five tasks for you: **Task 1 — Create `task/schemas.py`** with `ErrorOut`: @@ -162,6 +162,32 @@ The session endpoints already handle errors correctly. Read through `create_session` and `get_session` to see the same `HTTPException` pattern applied to POST and GET. +**Task 5 — Fix `task/routers/monsters.py`** + +`GET /monsters/{name}` is already registered — that's why `GET /monsters/Goblin` +already works. But look at its body: on an unknown name, `repo.get(name)` returns +`None`, and the next line calls `.name` on it, crashing with an `AttributeError` +(500) instead of a clean 404. This is the same fix as Task 2, applied to a lookup +instead of a simulation: + +```python +@router.get("/monsters/{name}", response_model=MonsterOut) +def get_monster( + name: str, + repo: MonsterRepository = Depends(get_monster_repo), +) -> MonsterOut: + monster = repo.get(name) + if monster is None: + raise HTTPException(status_code=404, detail=f"Monster '{name}' not found") + return MonsterOut(name=monster.name, hp=monster.hp, atk=monster.atk, defense=monster.def_, gold=monster.gold) +``` + +Notice `defense=monster.def_`: the domain model calls the field `def_` because `def` +is a reserved Python keyword, but nothing forces the public API to expose that +name. The schema boundary is exactly where such renaming belongs — it's the one +place code on both sides has to touch explicitly, so it's the right place to give +API clients the more readable `defense`. + ## Try It Yourself 1. Start the server: `uv run uvicorn task.main:app --reload` @@ -170,7 +196,9 @@ to POST and GET. 4. Try `POST /battle/simulate` with `monster_name: "Goblin"` — confirm 200. 5. Try `GET /heroes/classes` — confirm you see `["warrior", "mage", "rogue"]`. 6. Try `GET /sessions/fake-id-123` — confirm 404. -7. Open and check the 404 response schema appears +7. Try `GET /monsters/Goblin` — confirm 200. Try `GET /monsters/FakeMonster` — + confirm 404, not a 500 crash. +8. Open and check the 404 response schema appears for `/battle/simulate` after you add `responses={404: {"model": ErrorOut}}`. ## Break It @@ -189,23 +217,11 @@ to POST and GET. ## Side Quest -Add a `GET /monsters/{name}` endpoint that returns a single monster or 404: - -```python -@router.get( - "/monsters/{name}", - response_model=MonsterOut, - responses={404: {"model": ErrorOut}}, -) -def get_monster( - name: str, - service: BattleService = Depends(get_battle_service), -) -> MonsterOut: - monster = service._repo.get(name) - if monster is None: - raise HTTPException(status_code=404, detail=f"Monster '{name}' not found") - return MonsterOut(**vars(monster)) -``` +Your Task 5 fix for `get_monster` reaches into `repo` directly. Add a +`get_monster(name)` method on `BattleService` itself (it already has `_repo`) +and call `service.get_monster(name)` from the router instead. Routers should +generally go through the service, not the repository, once a service exists — +`get_monster` currently bypasses it. Notice `service._repo.get(name)` accesses the private attribute — that's a hint that a cleaner design would expose `get_monster(name)` on `BattleService` itself. @@ -232,4 +248,6 @@ about HTTP — it just raises a `ValueError`. The router is the translation laye - [ ] `POST /battle/simulate` with a known monster returns 200 - [ ] `GET /sessions/nonexistent` returns 404 - [ ] `GET /heroes/classes` returns 200 with a non-empty list of class names +- [ ] `GET /monsters/Goblin` (or any real monster) returns 200 +- [ ] `GET /monsters/{unknown name}` returns 404 (not 500) - [ ] `uv run python check.py` prints ✅ Mission 06 complete! diff --git a/level_6_api/missions/07_api_tests/check.py b/level_6_api/missions/07_api_tests/check.py index 6389ad7..30b6e6d 100644 --- a/level_6_api/missions/07_api_tests/check.py +++ b/level_6_api/missions/07_api_tests/check.py @@ -1,7 +1,18 @@ # level_6_api/missions/07_api_tests/check.py -"""Check: Mission 07 — API Tests.""" +"""Check: Mission 07 — API Tests. + +Structural checks confirm TestClient and dependency_overrides are used, no +mocking, and at least 6 test functions exist. The real proof of competence is +behavioral: the student's test suite must pass against the correct +application, then FAIL against each of six independent, single-behavior +mutants — one per test the mission's own README asks the student to write +(health status, monster list, battle winner, invalid-monster 404, session +round-trip, session-not-found 404). A suite of tests that make real requests +but assert nothing meaningful (`assert True`) kills none of these mutants. +""" import json import os +import shutil import subprocess import sys from pathlib import Path @@ -22,6 +33,25 @@ def update_progress(mission_id: str) -> None: PROGRESS_FILE.write_text(json.dumps(progress, indent=2)) +def _clear_pycache() -> None: + for d in mission.rglob("__pycache__"): + shutil.rmtree(d, ignore_errors=True) + + +def _run_pytest() -> subprocess.CompletedProcess: + env = os.environ.copy() + env["PYTHONPATH"] = str(mission) + env["PYTHONDONTWRITEBYTECODE"] = "1" + _clear_pycache() + try: + return subprocess.run( + [sys.executable, "-m", "pytest", "task/tests/test_api.py", "-q", "--tb=line"], + capture_output=True, text=True, cwd=str(mission), env=env, + ) + finally: + _clear_pycache() + + # ── test file exists ────────────────────────────────────────────────────────── test_file = mission / "task/tests/test_api.py" @@ -65,20 +95,104 @@ def update_progress(mission_id: str) -> None: raise SystemExit(1) print(f"✓ {test_fn_count} test functions found") -# ── pytest passes ───────────────────────────────────────────────────────────── +# ── Gate 1: the suite must pass against the correct application ────────────── -env = os.environ.copy() -env["PYTHONPATH"] = str(mission) -result = subprocess.run( - [sys.executable, "-m", "pytest", "task/tests/test_api.py", "-q", "--tb=short"], - capture_output=True, text=True, cwd=str(mission), env=env, -) +result = _run_pytest() if result.returncode != 0: - print("❌ pytest fails:") + print("❌ pytest fails against the correct application:") print(result.stdout[-2000:]) print(result.stderr[-500:]) raise SystemExit(1) -print("✓ All API tests pass") +print("✓ All API tests pass against the correct application") + +# ── Gate 2: the suite must FAIL against each single-behavior mutant ────────── +# +# One mutant per test the README asks for. Each is a real bug a client would +# notice; none of them touches anything not already covered by the mission's +# own 6 required behaviors. The hero-vs-Goblin matchup used in the mission's +# own test spec (warrior, atk=12/def_=6/hp=120 vs Goblin atk=8/def_=2/hp=30) +# is a guaranteed hero win regardless of dice rolls — worst case the hero +# takes 8 damage/round for at most 3 rounds (24 of 120 hp) while dealing a +# guaranteed kill to the goblin within 3 rounds — so asserting on the winner +# for that specific matchup is asserting a real invariant, not a coin flip. + +MAIN_FILE = mission / "task/main.py" +MONSTERS_FILE = mission / "task/routers/monsters.py" +BATTLES_FILE = mission / "task/routers/battles.py" +SESSIONS_FILE = mission / "task/routers/sessions.py" + +for path in (MAIN_FILE, MONSTERS_FILE, BATTLES_FILE, SESSIONS_FILE): + if not path.exists(): + print(f"❌ {path.relative_to(mission)} not found") + raise SystemExit(1) + +originals = {p: p.read_text() for p in (MAIN_FILE, MONSTERS_FILE, BATTLES_FILE, SESSIONS_FILE)} + +MUTANTS: list[tuple[str, Path, str, str]] = [ + ( + "health endpoint returns the wrong status", + MAIN_FILE, + ' return {"status": "ok"}', + ' return {"status": "degraded"}', + ), + ( + "GET /monsters returns an empty list regardless of the catalog", + MONSTERS_FILE, + " return [MonsterOut(name=m.name, hp=m.hp, atk=m.atk, defense=m.def_, gold=m.gold) for m in service.get_available_monsters()]", + " return []", + ), + ( + "POST /battle/simulate ignores the real outcome and always reports the monster winning " + "(for Ada the warrior vs Goblin, the hero winning is a guaranteed outcome, not chance)", + BATTLES_FILE, + " return BattleResultOut(**vars(result))", + ' return BattleResultOut(hero_name=result.hero_name, monster_name=result.monster_name, winner="monster", rounds=result.rounds, gold_earned=result.gold_earned)', + ), + ( + "POST /battle/simulate with an unknown monster returns 200 instead of 404", + BATTLES_FILE, + " try:\n result = service.simulate(hero, req.monster_name)\n except ValueError as exc:\n raise HTTPException(status_code=404, detail=str(exc)) from exc", + ' try:\n result = service.simulate(hero, req.monster_name)\n except ValueError:\n return BattleResultOut(hero_name=hero.name, monster_name=req.monster_name, winner="hero", rounds=1, gold_earned=0)', + ), + ( + "POST /sessions returns a session_id but never actually saves the session " + "(the GET half of the round-trip would then find nothing)", + SESSIONS_FILE, + " session_id = str(uuid.uuid4())\n session_repo.save(session_id, result)\n return SessionCreated(session_id=session_id)", + " session_id = str(uuid.uuid4())\n return SessionCreated(session_id=session_id)", + ), + ( + "GET /sessions/{id} returns 200 with a made-up result instead of 404 when the session doesn't exist", + SESSIONS_FILE, + ' result = session_repo.get(session_id)\n if result is None:\n raise HTTPException(status_code=404, detail=f"Session \'{session_id}\' not found")\n return BattleResultOut(**vars(result))', + ' result = session_repo.get(session_id)\n if result is None:\n return BattleResultOut(hero_name="Unknown", monster_name="Unknown", winner="hero", rounds=0, gold_earned=0)\n return BattleResultOut(**vars(result))', + ), +] + +try: + for label, path, old, new in MUTANTS: + original_src = originals[path] + if old not in original_src: + print(f"❌ internal check error: mutant anchor not found for: {label}") + raise SystemExit(1) + path.write_text(original_src.replace(old, new, 1)) + result = _run_pytest() + path.write_text(original_src) + if result.returncode == 0: + print(f"❌ Your tests did not catch this bug: {label}.") + print(" A correct test suite for this mission must fail when this " + "behavior breaks — check the actual response data, not just the status code.") + raise SystemExit(1) + print(f"✓ tests correctly fail when: {label}") +finally: + for path, original_src in originals.items(): + path.write_text(original_src) + _clear_pycache() + +for path, original_src in originals.items(): + if path.read_text() != original_src: + print(f"❌ internal check error: {path.relative_to(mission)} was not restored correctly — please re-run") + raise SystemExit(1) update_progress("07_api_tests") print("\n✅ Mission 07 complete!") diff --git a/level_6_api/projects/01_battle_preview_api/README.md b/level_6_api/projects/01_battle_preview_api/README.md index 7b5f893..9ad82ab 100644 --- a/level_6_api/projects/01_battle_preview_api/README.md +++ b/level_6_api/projects/01_battle_preview_api/README.md @@ -44,7 +44,7 @@ uv run python check.py ```python # Input HeroIn: name, hp (>0), atk (>0), def_ (≥0) -BattleRequest: hero: HeroIn, monster_name: str +BattleRequest: hero: HeroIn, monster_name: Literal["goblin", "orc", "dragon"] # Output HeroPreview: name, hp, atk, def_, power_rating # atk / (def_ + 1) @@ -55,10 +55,23 @@ BattleResult: winner ("hero" | "monster"), rounds, hero_hp_remaining - `power_rating = round(hero.atk / (hero.def_ + 1), 2)` - Each combat round: `damage = max(attacker.atk - defender.def_, 1)` -- Unknown `monster_name` → `HTTPException(status_code=404)` -- Invalid body (e.g. `hp=-1`) → 422 automatically (FastAPI/Pydantic) +- Invalid body (e.g. `hp=-1`, or a `monster_name` outside `Literal["goblin", "orc", "dragon"]`) → **422**, raised automatically by Pydantic before your endpoint body ever runs. - **No `APIRouter`** — that's Project 02. Keep this flat. +### Why 422 here, not 404 + +`monster_name` is typed as a `Literal` — the allowed set is part of the *request +schema*, not looked up in a datastore. An unrecognised value fails request +validation, so FastAPI never reaches `simulate_battle`; it's the same 422 you'd +get from `hp=-1`. That's different from a *lookup failure*: a resource that +passes validation but isn't found once you actually go looking for it (a +missing hero by ID, a session that expired) is what `HTTPException(404)` is +for. Project 02's `GET /monsters/{name}` is exactly that case — `name` is a +free-form path string, so any value passes validation, and the router itself +decides whether the monster exists. Don't read "422 for a bad name" as a +universal rule — it follows from *where* the allowed set is enforced, not from +what kind of value was wrong. + ## Monster catalog | Name | HP | ATK | DEF | diff --git a/level_6_api/projects/03_rpg_battle_api/README.md b/level_6_api/projects/03_rpg_battle_api/README.md index 4d004ae..e753b7d 100644 --- a/level_6_api/projects/03_rpg_battle_api/README.md +++ b/level_6_api/projects/03_rpg_battle_api/README.md @@ -8,11 +8,14 @@ A 7-endpoint HTTP API backed by a JSON session store. The domain layer (domain, ## What to implement -### 1. `rpg/api/schemas.py` — Pydantic API schemas +### 1. `rpg/api/schemas.py` — Pydantic API schemas (pre-wired — read, don't rewrite) + +These four schemas are already written for you. Read them before touching the +routers — every endpoint you implement returns one of these: | Schema | Fields | |--------|--------| -| `MonsterOut` | `name`, `hp`, `atk`, `def_`, `gold` | +| `MonsterOut` | `name`, `hp`, `atk`, `defense`, `gold` | | `BattleRequest` | `hero_name`, `hero_class` (HeroClass enum), `monster_name` | | `BattleResultOut` | `hero_name`, `monster_name`, `winner`, `rounds`, `gold_earned` | | `SessionCreated` | `session_id` | @@ -81,6 +84,7 @@ uv run python level_6_api/projects/03_rpg_battle_api/check.py | 7 | `GET /sessions/nonexistent-id` → 404 | | 8 | `GET /reports/{valid_id}` → 200, body contains `##` | | 9 | `pytest tests/test_api.py` → all pass | +| 10 | `ruff check rpg/api/` → no lint errors | ## Running tests manually @@ -92,10 +96,15 @@ uv run pytest tests/test_api.py -v ## Where to start 1. Read `rpg/domain.py`, `rpg/repositories.py`, and `rpg/services.py` to understand the pre-built layer. -2. Fill in `rpg/api/schemas.py` with the five Pydantic models. +2. Read `rpg/api/schemas.py` — the four Pydantic models are already written; you'll use them in the routers below. 3. Fill in `rpg/api/dependencies.py` with the three factory functions. 4. Implement `rpg/api/routers/health.py` and run `check.py` — Gate 1 should pass. 5. Work through the remaining routers one at a time, running `check.py` after each. 6. Implement the tests in `tests/test_api.py` last (or alongside each router). Good luck! + +--- + +The quality habits from Level 5 still apply: before shipping the API, run +`uv run ruff check rpg/api/` on the code you changed. diff --git a/level_6_api/projects/03_rpg_battle_api/check.py b/level_6_api/projects/03_rpg_battle_api/check.py index 027346d..e9228b5 100644 --- a/level_6_api/projects/03_rpg_battle_api/check.py +++ b/level_6_api/projects/03_rpg_battle_api/check.py @@ -152,6 +152,20 @@ def update_progress(project_id: str) -> None: raise SystemExit(1) print("✓ All API tests pass") +# ── Gate 10: ruff check on the code you wrote (Level 5 habits still apply) ─── + +repo_root = Path(__file__).parents[3] +result = subprocess.run( + [sys.executable, "-m", "ruff", "check", str(project / "rpg" / "api"), "--config", str(repo_root / "pyproject.toml")], + capture_output=True, + text=True, +) +if result.returncode != 0: + print("❌ ruff check rpg/api/ fails — fix the lint errors above:") + print(result.stdout) + raise SystemExit(1) +print("✓ ruff check rpg/api/ passes") + update_progress("03_rpg_battle_api") print() print("✅ Boss fight complete! RPG Battle API fully operational.")