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