Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion level_6_api/missions/01_first_fastapi_app/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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!")
68 changes: 42 additions & 26 deletions level_6_api/missions/05_dependencies_and_repositories/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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!")
56 changes: 37 additions & 19 deletions level_6_api/missions/06_api_errors_and_status_codes/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down Expand Up @@ -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`
Expand All @@ -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 <http://127.0.0.1:8000/docs> 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 <http://127.0.0.1:8000/docs> and check the 404 response schema appears
for `/battle/simulate` after you add `responses={404: {"model": ErrorOut}}`.

## Break It
Expand All @@ -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.
Expand All @@ -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!
134 changes: 124 additions & 10 deletions level_6_api/missions/07_api_tests/check.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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"
Expand Down Expand Up @@ -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!")
Loading
Loading