"""Integration tests for the Runs & Encounters API.""" import pytest from httpx import AsyncClient from sqlalchemy.ext.asyncio import AsyncSession from app.models.game import Game from app.models.nuzlocke_run import NuzlockeRun from app.models.pokemon import Pokemon from app.models.route import Route from app.models.version_group import VersionGroup RUNS_BASE = "/api/v1/runs" ENC_BASE = "/api/v1/encounters" # --------------------------------------------------------------------------- # Shared fixtures # --------------------------------------------------------------------------- @pytest.fixture async def game_id(db_session: AsyncSession) -> int: """A minimal game (no version_group_id needed for run CRUD).""" game = Game(name="Test Game", slug="test-game", generation=1, region="kanto") db_session.add(game) await db_session.commit() await db_session.refresh(game) return game.id @pytest.fixture async def run(client: AsyncClient, game_id: int) -> dict: """An active run created via the API.""" response = await client.post(RUNS_BASE, json={"gameId": game_id, "name": "My Run"}) assert response.status_code == 201 return response.json() @pytest.fixture async def enc_ctx(db_session: AsyncSession) -> dict: """Full context for encounter tests: game, run, pokemon, standalone and grouped routes.""" vg = VersionGroup(name="Enc Test VG", slug="enc-test-vg") db_session.add(vg) await db_session.flush() game = Game( name="Enc Game", slug="enc-game", generation=1, region="kanto", version_group_id=vg.id, ) db_session.add(game) await db_session.flush() pikachu = Pokemon( pokeapi_id=25, national_dex=25, name="pikachu", types=["electric"] ) charmander = Pokemon( pokeapi_id=4, national_dex=4, name="charmander", types=["fire"] ) db_session.add_all([pikachu, charmander]) await db_session.flush() # A standalone route (no parent — no route-lock applies) standalone = Route(name="Standalone Route", version_group_id=vg.id, order=1) # A parent route with two children (route-lock applies to children) parent = Route(name="Route Group", version_group_id=vg.id, order=2) db_session.add_all([standalone, parent]) await db_session.flush() child1 = Route( name="Child A", version_group_id=vg.id, order=1, parent_route_id=parent.id ) child2 = Route( name="Child B", version_group_id=vg.id, order=2, parent_route_id=parent.id ) db_session.add_all([child1, child2]) await db_session.flush() run = NuzlockeRun( game_id=game.id, name="Enc Run", status="active", rules={"shinyClause": True, "giftClause": False}, ) db_session.add(run) await db_session.commit() for obj in [standalone, parent, child1, child2, pikachu, charmander, run]: await db_session.refresh(obj) return { "run_id": run.id, "game_id": game.id, "pikachu_id": pikachu.id, "charmander_id": charmander.id, "standalone_id": standalone.id, "parent_id": parent.id, "child1_id": child1.id, "child2_id": child2.id, } # --------------------------------------------------------------------------- # Runs — list # --------------------------------------------------------------------------- class TestListRuns: async def test_empty_returns_empty_list(self, client: AsyncClient): response = await client.get(RUNS_BASE) assert response.status_code == 200 assert response.json() == [] async def test_returns_created_run(self, client: AsyncClient, run: dict): response = await client.get(RUNS_BASE) assert response.status_code == 200 ids = [r["id"] for r in response.json()] assert run["id"] in ids # --------------------------------------------------------------------------- # Runs — create # --------------------------------------------------------------------------- class TestCreateRun: async def test_creates_active_run(self, client: AsyncClient, game_id: int): response = await client.post( RUNS_BASE, json={"gameId": game_id, "name": "New Run"} ) assert response.status_code == 201 data = response.json() assert data["name"] == "New Run" assert data["status"] == "active" assert data["gameId"] == game_id assert isinstance(data["id"], int) async def test_rules_stored(self, client: AsyncClient, game_id: int): rules = {"duplicatesClause": True, "shinyClause": False} response = await client.post( RUNS_BASE, json={"gameId": game_id, "name": "Run", "rules": rules} ) assert response.status_code == 201 assert response.json()["rules"]["duplicatesClause"] is True async def test_invalid_game_returns_404(self, client: AsyncClient): response = await client.post(RUNS_BASE, json={"gameId": 9999, "name": "Run"}) assert response.status_code == 404 async def test_missing_required_returns_422(self, client: AsyncClient): response = await client.post(RUNS_BASE, json={"name": "Run"}) assert response.status_code == 422 # --------------------------------------------------------------------------- # Runs — get # --------------------------------------------------------------------------- class TestGetRun: async def test_returns_run_with_game_and_encounters( self, client: AsyncClient, run: dict ): response = await client.get(f"{RUNS_BASE}/{run['id']}") assert response.status_code == 200 data = response.json() assert data["id"] == run["id"] assert "game" in data assert data["encounters"] == [] async def test_not_found_returns_404(self, client: AsyncClient): assert (await client.get(f"{RUNS_BASE}/9999")).status_code == 404 # --------------------------------------------------------------------------- # Runs — update # --------------------------------------------------------------------------- class TestUpdateRun: async def test_updates_name(self, client: AsyncClient, run: dict): response = await client.patch( f"{RUNS_BASE}/{run['id']}", json={"name": "Renamed"} ) assert response.status_code == 200 assert response.json()["name"] == "Renamed" async def test_complete_run_sets_completed_at(self, client: AsyncClient, run: dict): response = await client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "completed"} ) assert response.status_code == 200 data = response.json() assert data["status"] == "completed" assert data["completedAt"] is not None async def test_fail_run(self, client: AsyncClient, run: dict): response = await client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} ) assert response.status_code == 200 assert response.json()["status"] == "failed" async def test_ending_already_ended_run_returns_400( self, client: AsyncClient, run: dict ): await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}) response = await client.patch( f"{RUNS_BASE}/{run['id']}", json={"status": "failed"} ) assert response.status_code == 400 async def test_not_found_returns_404(self, client: AsyncClient): assert ( await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"}) ).status_code == 404 # --------------------------------------------------------------------------- # Runs — delete # --------------------------------------------------------------------------- class TestDeleteRun: async def test_deletes_run(self, client: AsyncClient, run: dict): assert (await client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204 assert (await client.get(f"{RUNS_BASE}/{run['id']}")).status_code == 404 async def test_not_found_returns_404(self, client: AsyncClient): assert (await client.delete(f"{RUNS_BASE}/9999")).status_code == 404 # --------------------------------------------------------------------------- # Encounters — create # --------------------------------------------------------------------------- class TestCreateEncounter: async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict): response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) assert response.status_code == 201 data = response.json() assert data["runId"] == enc_ctx["run_id"] assert data["pokemonId"] == enc_ctx["pikachu_id"] assert data["status"] == "caught" assert data["isShiny"] is False async def test_invalid_run_returns_404(self, client: AsyncClient, enc_ctx: dict): response = await client.post( f"{RUNS_BASE}/9999/encounters", json={ "routeId": enc_ctx["standalone_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) assert response.status_code == 404 async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict): response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": 9999, "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) assert response.status_code == 404 async def test_invalid_pokemon_returns_404( self, client: AsyncClient, enc_ctx: dict ): response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], "pokemonId": 9999, "status": "caught", }, ) assert response.status_code == 404 async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict): """Cannot create an encounter directly on a parent route (use child routes).""" response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["parent_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) assert response.status_code == 400 async def test_route_lock_prevents_second_sibling_encounter( self, client: AsyncClient, enc_ctx: dict ): """Once a sibling child has an encounter, other siblings in the group return 409.""" await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], "pokemonId": enc_ctx["charmander_id"], "status": "caught", }, ) assert response.status_code == 409 async def test_shiny_bypasses_route_lock( self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession ): """A shiny encounter bypasses the route-lock when shinyClause is enabled.""" # First encounter occupies the group await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) # Shiny encounter on sibling should succeed response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], "pokemonId": enc_ctx["charmander_id"], "status": "caught", "isShiny": True, }, ) assert response.status_code == 201 assert response.json()["isShiny"] is True async def test_gift_bypasses_route_lock_when_clause_on( self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession ): """A gift encounter bypasses route-lock when giftClause is enabled.""" # Enable giftClause on the run run = await db_session.get(NuzlockeRun, enc_ctx["run_id"]) run.rules = {"shinyClause": True, "giftClause": True} await db_session.commit() await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child1_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["child2_id"], "pokemonId": enc_ctx["charmander_id"], "status": "caught", "origin": "gift", }, ) assert response.status_code == 201 assert response.json()["origin"] == "gift" # --------------------------------------------------------------------------- # Encounters — update # --------------------------------------------------------------------------- class TestUpdateEncounter: @pytest.fixture async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) return response.json() async def test_updates_nickname(self, client: AsyncClient, encounter: dict): response = await client.patch( f"{ENC_BASE}/{encounter['id']}", json={"nickname": "Sparky"} ) assert response.status_code == 200 assert response.json()["nickname"] == "Sparky" async def test_updates_status_to_fainted( self, client: AsyncClient, encounter: dict ): response = await client.patch( f"{ENC_BASE}/{encounter['id']}", json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"}, ) assert response.status_code == 200 data = response.json() assert data["status"] == "fainted" assert data["faintLevel"] == 12 assert data["deathCause"] == "wild battle" async def test_not_found_returns_404(self, client: AsyncClient): assert ( await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"}) ).status_code == 404 # --------------------------------------------------------------------------- # Encounters — delete # --------------------------------------------------------------------------- class TestDeleteEncounter: @pytest.fixture async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict: response = await client.post( f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters", json={ "routeId": enc_ctx["standalone_id"], "pokemonId": enc_ctx["pikachu_id"], "status": "caught", }, ) return response.json() async def test_deletes_encounter( self, client: AsyncClient, encounter: dict, enc_ctx: dict ): assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204 # Run detail should no longer include it detail = (await client.get(f"{RUNS_BASE}/{enc_ctx['run_id']}")).json() assert all(e["id"] != encounter["id"] for e in detail["encounters"]) async def test_not_found_returns_404(self, client: AsyncClient): assert (await client.delete(f"{ENC_BASE}/9999")).status_code == 404