diff --git a/.beans/nuzlocke-tracker-9c66--integration-tests-for-genlockes-bosses-api.md b/.beans/nuzlocke-tracker-9c66--integration-tests-for-genlockes-bosses-api.md index d6087e1..2f51ddb 100644 --- a/.beans/nuzlocke-tracker-9c66--integration-tests-for-genlockes-bosses-api.md +++ b/.beans/nuzlocke-tracker-9c66--integration-tests-for-genlockes-bosses-api.md @@ -1,10 +1,11 @@ --- # nuzlocke-tracker-9c66 title: Integration tests for Genlockes & Bosses API -status: draft +status: completed type: task +priority: normal created_at: 2026-02-10T09:33:26Z -updated_at: 2026-02-10T09:33:26Z +updated_at: 2026-02-21T12:20:37Z parent: nuzlocke-tracker-yzpb --- @@ -12,14 +13,14 @@ Write integration tests for the genlocke challenge and boss battle API endpoints ## Checklist -- [ ] Test genlocke CRUD operations (create, list, get, update, delete) -- [ ] Test leg management (add/remove legs to a genlocke) -- [ ] Test Pokemon transfers between genlocke legs -- [ ] Test boss battle CRUD (create, list, update, delete per game) -- [ ] Test boss battle results per run (record win/loss) -- [ ] Test stats endpoint for run statistics -- [ ] Test export endpoint -- [ ] Test error cases (invalid transfers, boss results for wrong game, etc.) +- [x] Test genlocke CRUD operations (create, list, get, update, delete) +- [x] Test leg management (add/remove legs to a genlocke) +- [x] Test Pokemon transfers between genlocke legs +- [x] Test boss battle CRUD (create, list, update, delete per game) +- [x] Test boss battle results per run (record win/loss) +- [x] Test stats endpoint for run statistics +- [x] Test export endpoint +- [x] Test error cases (invalid transfers, boss results for wrong game, etc.) ## Notes diff --git a/backend/tests/test_genlocke_boss.py b/backend/tests/test_genlocke_boss.py new file mode 100644 index 0000000..feede5d --- /dev/null +++ b/backend/tests/test_genlocke_boss.py @@ -0,0 +1,594 @@ +"""Integration tests for the Genlockes & Bosses API.""" + +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.game import Game +from app.models.pokemon import Pokemon +from app.models.route import Route +from app.models.version_group import VersionGroup + +GENLOCKES_BASE = "/api/v1/genlockes" +RUNS_BASE = "/api/v1/runs" +GAMES_BASE = "/api/v1/games" +STATS_BASE = "/api/v1/stats" +EXPORT_BASE = "/api/v1/export" + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def games_ctx(db_session: AsyncSession) -> dict: + """Two games with version groups.""" + vg1 = VersionGroup(name="GT VG1", slug="gt-vg1") + vg2 = VersionGroup(name="GT VG2", slug="gt-vg2") + db_session.add_all([vg1, vg2]) + await db_session.flush() + + game1 = Game( + name="GT Game 1", + slug="gt-game-1", + generation=1, + region="kanto", + version_group_id=vg1.id, + ) + game2 = Game( + name="GT Game 2", + slug="gt-game-2", + generation=2, + region="johto", + version_group_id=vg2.id, + ) + db_session.add_all([game1, game2]) + await db_session.commit() + + return { + "game1_id": game1.id, + "game2_id": game2.id, + "vg1_id": vg1.id, + "vg2_id": vg2.id, + } + + +@pytest.fixture +async def ctx(db_session: AsyncSession, client: AsyncClient, games_ctx: dict) -> dict: + """Full context: routes + pokemon + genlocke + encounter for advance/transfer tests.""" + route1 = Route(name="GT Route 1", version_group_id=games_ctx["vg1_id"], order=1) + route2 = Route(name="GT Route 2", version_group_id=games_ctx["vg2_id"], order=1) + db_session.add_all([route1, route2]) + + pikachu = Pokemon( + pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"] + ) + db_session.add(pikachu) + await db_session.commit() + + r = await client.post( + GENLOCKES_BASE, + json={ + "name": "Test Genlocke", + "gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]], + }, + ) + assert r.status_code == 201 + genlocke = r.json() + + leg1 = next(leg for leg in genlocke["legs"] if leg["legOrder"] == 1) + run_id = leg1["runId"] + + enc_r = await client.post( + f"{RUNS_BASE}/{run_id}/encounters", + json={"routeId": route1.id, "pokemonId": pikachu.id, "status": "caught"}, + ) + assert enc_r.status_code == 201 + + return { + **games_ctx, + "route1_id": route1.id, + "route2_id": route2.id, + "pikachu_id": pikachu.id, + "genlocke_id": genlocke["id"], + "run_id": run_id, + "encounter_id": enc_r.json()["id"], + "genlocke": genlocke, + } + + +# --------------------------------------------------------------------------- +# Genlockes — list +# --------------------------------------------------------------------------- + + +class TestListGenlockes: + async def test_empty_returns_empty_list(self, client: AsyncClient): + response = await client.get(GENLOCKES_BASE) + assert response.status_code == 200 + assert response.json() == [] + + async def test_returns_created_genlocke(self, client: AsyncClient, ctx: dict): + response = await client.get(GENLOCKES_BASE) + assert response.status_code == 200 + names = [g["name"] for g in response.json()] + assert "Test Genlocke" in names + + +# --------------------------------------------------------------------------- +# Genlockes — create +# --------------------------------------------------------------------------- + + +class TestCreateGenlocke: + async def test_creates_with_legs_and_first_run( + self, client: AsyncClient, games_ctx: dict + ): + response = await client.post( + GENLOCKES_BASE, + json={ + "name": "My Genlocke", + "gameIds": [games_ctx["game1_id"], games_ctx["game2_id"]], + }, + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "My Genlocke" + assert data["status"] == "active" + assert len(data["legs"]) == 2 + # Leg 1 should already have a run linked + leg1 = next(leg for leg in data["legs"] if leg["legOrder"] == 1) + assert leg1["runId"] is not None + # Leg 2 should not yet have a run + leg2 = next(leg for leg in data["legs"] if leg["legOrder"] == 2) + assert leg2["runId"] is None + + async def test_empty_game_ids_returns_400(self, client: AsyncClient): + response = await client.post( + GENLOCKES_BASE, json={"name": "Bad", "gameIds": []} + ) + assert response.status_code == 400 + + async def test_invalid_game_id_returns_404(self, client: AsyncClient): + response = await client.post( + GENLOCKES_BASE, json={"name": "Bad", "gameIds": [9999]} + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Genlockes — get +# --------------------------------------------------------------------------- + + +class TestGetGenlocke: + async def test_returns_genlocke_with_legs_and_stats( + self, client: AsyncClient, ctx: dict + ): + response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") + assert response.status_code == 200 + data = response.json() + assert data["id"] == ctx["genlocke_id"] + assert len(data["legs"]) == 2 + assert "stats" in data + assert data["stats"]["totalLegs"] == 2 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{GENLOCKES_BASE}/9999")).status_code == 404 + + +# --------------------------------------------------------------------------- +# Genlockes — update / delete +# --------------------------------------------------------------------------- + + +class TestUpdateGenlocke: + async def test_updates_name(self, client: AsyncClient, ctx: dict): + response = await client.patch( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}", json={"name": "Renamed"} + ) + assert response.status_code == 200 + assert response.json()["name"] == "Renamed" + + async def test_not_found_returns_404(self, client: AsyncClient): + assert ( + await client.patch(f"{GENLOCKES_BASE}/9999", json={"name": "x"}) + ).status_code == 404 + + +class TestDeleteGenlocke: + async def test_deletes_genlocke(self, client: AsyncClient, ctx: dict): + assert ( + await client.delete(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") + ).status_code == 204 + assert ( + await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}") + ).status_code == 404 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.delete(f"{GENLOCKES_BASE}/9999")).status_code == 404 + + +# --------------------------------------------------------------------------- +# Genlockes — legs (add / remove) +# --------------------------------------------------------------------------- + + +class TestGenlockeLegs: + async def test_adds_leg(self, client: AsyncClient, ctx: dict): + response = await client.post( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs", + json={"gameId": ctx["game1_id"]}, + ) + assert response.status_code == 201 + legs = response.json()["legs"] + assert len(legs) == 3 # was 2, now 3 + + async def test_remove_leg_without_run(self, client: AsyncClient, ctx: dict): + # Leg 2 has no run yet — can be removed + leg2 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 2) + response = await client.delete( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg2['id']}" + ) + assert response.status_code == 204 + + async def test_remove_leg_with_run_returns_400( + self, client: AsyncClient, ctx: dict + ): + # Leg 1 has a run — cannot remove + leg1 = next(leg for leg in ctx["genlocke"]["legs"] if leg["legOrder"] == 1) + response = await client.delete( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/{leg1['id']}" + ) + assert response.status_code == 400 + + async def test_add_leg_invalid_game_returns_404( + self, client: AsyncClient, ctx: dict + ): + response = await client.post( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs", + json={"gameId": 9999}, + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Genlockes — advance leg +# --------------------------------------------------------------------------- + + +class TestAdvanceLeg: + async def test_uncompleted_run_returns_400(self, client: AsyncClient, ctx: dict): + """Cannot advance when leg 1's run is still active.""" + response = await client.post( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" + ) + assert response.status_code == 400 + + async def test_no_next_leg_returns_400(self, client: AsyncClient, games_ctx: dict): + """A single-leg genlocke cannot be advanced.""" + r = await client.post( + GENLOCKES_BASE, + json={"name": "Single Leg", "gameIds": [games_ctx["game1_id"]]}, + ) + genlocke = r.json() + run_id = genlocke["legs"][0]["runId"] + await client.patch(f"{RUNS_BASE}/{run_id}", json={"status": "completed"}) + + response = await client.post( + f"{GENLOCKES_BASE}/{genlocke['id']}/legs/1/advance" + ) + assert response.status_code == 400 + + async def test_advances_to_next_leg(self, client: AsyncClient, ctx: dict): + """Completing the current run allows advancing to the next leg.""" + await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) + + response = await client.post( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance" + ) + assert response.status_code == 200 + legs = response.json()["legs"] + leg2 = next(leg for leg in legs if leg["legOrder"] == 2) + assert leg2["runId"] is not None + + async def test_advances_with_transfers(self, client: AsyncClient, ctx: dict): + """Advancing with transfer_encounter_ids creates egg encounters in the next leg.""" + await client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}) + + response = await client.post( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance", + json={"transferEncounterIds": [ctx["encounter_id"]]}, + ) + assert response.status_code == 200 + legs = response.json()["legs"] + leg2 = next(leg for leg in legs if leg["legOrder"] == 2) + new_run_id = leg2["runId"] + assert new_run_id is not None + + # The new run should contain the transferred (egg) encounter + run_detail = (await client.get(f"{RUNS_BASE}/{new_run_id}")).json() + assert len(run_detail["encounters"]) == 1 + + +# --------------------------------------------------------------------------- +# Genlockes — read-only detail endpoints +# --------------------------------------------------------------------------- + + +class TestGenlockeGraveyard: + async def test_returns_empty_graveyard(self, client: AsyncClient, ctx: dict): + response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard") + assert response.status_code == 200 + data = response.json() + assert data["entries"] == [] + assert data["totalDeaths"] == 0 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404 + + +class TestGenlockeLineages: + async def test_returns_empty_lineages(self, client: AsyncClient, ctx: dict): + response = await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages") + assert response.status_code == 200 + data = response.json() + assert data["lineages"] == [] + assert data["totalLineages"] == 0 + + async def test_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404 + + +class TestGenlockeRetiredFamilies: + async def test_returns_empty_retired_families(self, client: AsyncClient, ctx: dict): + response = await client.get( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families" + ) + assert response.status_code == 200 + data = response.json() + assert data["retired_pokemon_ids"] == [] + + async def test_not_found_returns_404(self, client: AsyncClient): + assert ( + await client.get(f"{GENLOCKES_BASE}/9999/retired-families") + ).status_code == 404 + + +class TestLegSurvivors: + async def test_returns_survivors(self, client: AsyncClient, ctx: dict): + """The one caught encounter in leg 1 shows up as a survivor.""" + response = await client.get( + f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/survivors" + ) + assert response.status_code == 200 + assert len(response.json()) == 1 + + async def test_leg_not_found_returns_404(self, client: AsyncClient, ctx: dict): + assert ( + await client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors") + ).status_code == 404 + + +# --------------------------------------------------------------------------- +# Boss battles — CRUD (game-scoped) +# --------------------------------------------------------------------------- + +BOSS_PAYLOAD = { + "name": "Brock", + "bossType": "gym", + "levelCap": 14, + "order": 1, + "location": "Pewter City", +} + + +class TestBossCRUD: + async def test_empty_list(self, client: AsyncClient, games_ctx: dict): + response = await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses") + assert response.status_code == 200 + assert response.json() == [] + + async def test_creates_boss(self, client: AsyncClient, games_ctx: dict): + response = await client.post( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD + ) + assert response.status_code == 201 + data = response.json() + assert data["name"] == "Brock" + assert data["levelCap"] == 14 + assert data["pokemon"] == [] + + async def test_updates_boss(self, client: AsyncClient, games_ctx: dict): + boss = ( + await client.post( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD + ) + ).json() + response = await client.put( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}", + json={"levelCap": 20}, + ) + assert response.status_code == 200 + assert response.json()["levelCap"] == 20 + + async def test_deletes_boss(self, client: AsyncClient, games_ctx: dict): + boss = ( + await client.post( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD + ) + ).json() + assert ( + await client.delete( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/{boss['id']}" + ) + ).status_code == 204 + assert ( + await client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses") + ).json() == [] + + async def test_boss_not_found_returns_404( + self, client: AsyncClient, games_ctx: dict + ): + assert ( + await client.put( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses/9999", + json={"levelCap": 10}, + ) + ).status_code == 404 + + async def test_invalid_game_returns_404(self, client: AsyncClient): + assert (await client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404 + + async def test_game_without_version_group_returns_400(self, client: AsyncClient): + game = ( + await client.post( + GAMES_BASE, + json={ + "name": "No VG", + "slug": "no-vg", + "generation": 1, + "region": "kanto", + }, + ) + ).json() + assert ( + await client.get(f"{GAMES_BASE}/{game['id']}/bosses") + ).status_code == 400 + + +# --------------------------------------------------------------------------- +# Boss results — CRUD (run-scoped) +# --------------------------------------------------------------------------- + + +class TestBossResults: + @pytest.fixture + async def boss_ctx(self, client: AsyncClient, games_ctx: dict) -> dict: + """A boss battle and a run for boss-result tests.""" + boss = ( + await client.post( + f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses", json=BOSS_PAYLOAD + ) + ).json() + run = ( + await client.post( + RUNS_BASE, json={"gameId": games_ctx["game1_id"], "name": "Boss Run"} + ) + ).json() + return {"boss_id": boss["id"], "run_id": run["id"]} + + async def test_empty_list(self, client: AsyncClient, boss_ctx: dict): + response = await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") + assert response.status_code == 200 + assert response.json() == [] + + async def test_creates_boss_result(self, client: AsyncClient, boss_ctx: dict): + response = await client.post( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", + json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1}, + ) + assert response.status_code == 201 + data = response.json() + assert data["result"] == "won" + assert data["attempts"] == 1 + assert data["completedAt"] is not None + + async def test_upserts_existing_result(self, client: AsyncClient, boss_ctx: dict): + """POSTing the same boss twice updates the result (upsert).""" + await client.post( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", + json={"bossBattleId": boss_ctx["boss_id"], "result": "won", "attempts": 1}, + ) + response = await client.post( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", + json={"bossBattleId": boss_ctx["boss_id"], "result": "lost", "attempts": 3}, + ) + assert response.status_code == 201 + assert response.json()["result"] == "lost" + assert response.json()["attempts"] == 3 + # Still only one record + all_results = ( + await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") + ).json() + assert len(all_results) == 1 + + async def test_deletes_boss_result(self, client: AsyncClient, boss_ctx: dict): + result = ( + await client.post( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", + json={"bossBattleId": boss_ctx["boss_id"], "result": "won"}, + ) + ).json() + assert ( + await client.delete( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results/{result['id']}" + ) + ).status_code == 204 + assert ( + await client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results") + ).json() == [] + + async def test_invalid_run_returns_404(self, client: AsyncClient, boss_ctx: dict): + assert (await client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404 + + async def test_invalid_boss_returns_404(self, client: AsyncClient, boss_ctx: dict): + response = await client.post( + f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results", + json={"bossBattleId": 9999, "result": "won"}, + ) + assert response.status_code == 404 + + +# --------------------------------------------------------------------------- +# Stats +# --------------------------------------------------------------------------- + + +class TestStats: + async def test_returns_stats_structure(self, client: AsyncClient): + response = await client.get(STATS_BASE) + assert response.status_code == 200 + data = response.json() + assert data["totalRuns"] == 0 + assert data["totalEncounters"] == 0 + assert data["topCaughtPokemon"] == [] + assert data["typeDistribution"] == [] + + async def test_reflects_created_data(self, client: AsyncClient, ctx: dict): + """Stats should reflect the run and encounter created in ctx.""" + response = await client.get(STATS_BASE) + assert response.status_code == 200 + data = response.json() + assert data["totalRuns"] >= 1 + assert data["totalEncounters"] >= 1 + assert data["caughtCount"] >= 1 + + +# --------------------------------------------------------------------------- +# Export +# --------------------------------------------------------------------------- + + +class TestExport: + async def test_export_games_returns_list(self, client: AsyncClient): + response = await client.get(f"{EXPORT_BASE}/games") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + async def test_export_pokemon_returns_list(self, client: AsyncClient): + response = await client.get(f"{EXPORT_BASE}/pokemon") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + async def test_export_evolutions_returns_list(self, client: AsyncClient): + response = await client.get(f"{EXPORT_BASE}/evolutions") + assert response.status_code == 200 + assert isinstance(response.json(), list) + + async def test_export_game_routes_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404 + + async def test_export_game_bosses_not_found_returns_404(self, client: AsyncClient): + assert (await client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404