feat: add auth system, boss pokemon details, moves/abilities API, and run ownership
Some checks failed
CI / backend-tests (push) Failing after 1m16s
CI / frontend-tests (push) Successful in 57s

Add user authentication with login/signup/protected routes, boss pokemon
detail fields and result team tracking, moves and abilities selector
components and API, run ownership and visibility controls, and various
UI improvements across encounters, run list, and journal pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-20 21:41:38 +01:00
parent a6cb309b8b
commit 0a519e356e
69 changed files with 3574 additions and 693 deletions

View File

@@ -30,9 +30,11 @@ async def game_id(db_session: AsyncSession) -> int:
@pytest.fixture
async def run(client: AsyncClient, game_id: int) -> dict:
async def run(auth_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"})
response = await auth_client.post(
RUNS_BASE, json={"gameId": game_id, "name": "My Run"}
)
assert response.status_code == 201
return response.json()
@@ -127,8 +129,8 @@ class TestListRuns:
class TestCreateRun:
async def test_creates_active_run(self, client: AsyncClient, game_id: int):
response = await client.post(
async def test_creates_active_run(self, auth_client: AsyncClient, game_id: int):
response = await auth_client.post(
RUNS_BASE, json={"gameId": game_id, "name": "New Run"}
)
assert response.status_code == 201
@@ -138,20 +140,22 @@ class TestCreateRun:
assert data["gameId"] == game_id
assert isinstance(data["id"], int)
async def test_rules_stored(self, client: AsyncClient, game_id: int):
async def test_rules_stored(self, auth_client: AsyncClient, game_id: int):
rules = {"duplicatesClause": True, "shinyClause": False}
response = await client.post(
response = await auth_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"})
async def test_invalid_game_returns_404(self, auth_client: AsyncClient):
response = await auth_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"})
async def test_missing_required_returns_422(self, auth_client: AsyncClient):
response = await auth_client.post(RUNS_BASE, json={"name": "Run"})
assert response.status_code == 422
@@ -181,15 +185,17 @@ class TestGetRun:
class TestUpdateRun:
async def test_updates_name(self, client: AsyncClient, run: dict):
response = await client.patch(
async def test_updates_name(self, auth_client: AsyncClient, run: dict):
response = await auth_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(
async def test_complete_run_sets_completed_at(
self, auth_client: AsyncClient, run: dict
):
response = await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
)
assert response.status_code == 200
@@ -197,25 +203,27 @@ class TestUpdateRun:
assert data["status"] == "completed"
assert data["completedAt"] is not None
async def test_fail_run(self, client: AsyncClient, run: dict):
response = await client.patch(
async def test_fail_run(self, auth_client: AsyncClient, run: dict):
response = await auth_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
self, auth_client: AsyncClient, run: dict
):
await client.patch(f"{RUNS_BASE}/{run['id']}", json={"status": "completed"})
response = await client.patch(
await auth_client.patch(
f"{RUNS_BASE}/{run['id']}", json={"status": "completed"}
)
response = await auth_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):
async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (
await client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
await auth_client.patch(f"{RUNS_BASE}/9999", json={"name": "x"})
).status_code == 404
@@ -225,12 +233,12 @@ class TestUpdateRun:
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_deletes_run(self, auth_client: AsyncClient, run: dict):
assert (await auth_client.delete(f"{RUNS_BASE}/{run['id']}")).status_code == 204
assert (await auth_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
async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await auth_client.delete(f"{RUNS_BASE}/9999")).status_code == 404
# ---------------------------------------------------------------------------
@@ -239,8 +247,8 @@ class TestDeleteRun:
class TestCreateEncounter:
async def test_creates_encounter(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
async def test_creates_encounter(self, auth_client: AsyncClient, enc_ctx: dict):
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
@@ -255,8 +263,10 @@ class TestCreateEncounter:
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(
async def test_invalid_run_returns_404(
self, auth_client: AsyncClient, enc_ctx: dict
):
response = await auth_client.post(
f"{RUNS_BASE}/9999/encounters",
json={
"routeId": enc_ctx["standalone_id"],
@@ -266,8 +276,10 @@ class TestCreateEncounter:
)
assert response.status_code == 404
async def test_invalid_route_returns_404(self, client: AsyncClient, enc_ctx: dict):
response = await client.post(
async def test_invalid_route_returns_404(
self, auth_client: AsyncClient, enc_ctx: dict
):
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": 9999,
@@ -278,9 +290,9 @@ class TestCreateEncounter:
assert response.status_code == 404
async def test_invalid_pokemon_returns_404(
self, client: AsyncClient, enc_ctx: dict
self, auth_client: AsyncClient, enc_ctx: dict
):
response = await client.post(
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
@@ -290,9 +302,11 @@ class TestCreateEncounter:
)
assert response.status_code == 404
async def test_parent_route_rejected_400(self, client: AsyncClient, enc_ctx: dict):
async def test_parent_route_rejected_400(
self, auth_client: AsyncClient, enc_ctx: dict
):
"""Cannot create an encounter directly on a parent route (use child routes)."""
response = await client.post(
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["parent_id"],
@@ -303,10 +317,10 @@ class TestCreateEncounter:
assert response.status_code == 400
async def test_route_lock_prevents_second_sibling_encounter(
self, client: AsyncClient, enc_ctx: dict
self, auth_client: AsyncClient, enc_ctx: dict
):
"""Once a sibling child has an encounter, other siblings in the group return 409."""
await client.post(
await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
@@ -314,7 +328,7 @@ class TestCreateEncounter:
"status": "caught",
},
)
response = await client.post(
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
@@ -325,11 +339,11 @@ class TestCreateEncounter:
assert response.status_code == 409
async def test_shiny_bypasses_route_lock(
self, client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
self, auth_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(
await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
@@ -338,7 +352,7 @@ class TestCreateEncounter:
},
)
# Shiny encounter on sibling should succeed
response = await client.post(
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
@@ -351,7 +365,7 @@ class TestCreateEncounter:
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
self, auth_client: AsyncClient, enc_ctx: dict, db_session: AsyncSession
):
"""A gift encounter bypasses route-lock when giftClause is enabled."""
# Enable giftClause on the run
@@ -359,7 +373,7 @@ class TestCreateEncounter:
run.rules = {"shinyClause": True, "giftClause": True}
await db_session.commit()
await client.post(
await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child1_id"],
@@ -367,7 +381,7 @@ class TestCreateEncounter:
"status": "caught",
},
)
response = await client.post(
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["child2_id"],
@@ -387,8 +401,8 @@ class TestCreateEncounter:
class TestUpdateEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
@@ -398,17 +412,17 @@ class TestUpdateEncounter:
)
return response.json()
async def test_updates_nickname(self, client: AsyncClient, encounter: dict):
response = await client.patch(
async def test_updates_nickname(self, auth_client: AsyncClient, encounter: dict):
response = await auth_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
self, auth_client: AsyncClient, encounter: dict
):
response = await client.patch(
response = await auth_client.patch(
f"{ENC_BASE}/{encounter['id']}",
json={"status": "fainted", "faintLevel": 12, "deathCause": "wild battle"},
)
@@ -418,9 +432,9 @@ class TestUpdateEncounter:
assert data["faintLevel"] == 12
assert data["deathCause"] == "wild battle"
async def test_not_found_returns_404(self, client: AsyncClient):
async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (
await client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
await auth_client.patch(f"{ENC_BASE}/9999", json={"nickname": "x"})
).status_code == 404
@@ -431,8 +445,8 @@ class TestUpdateEncounter:
class TestDeleteEncounter:
@pytest.fixture
async def encounter(self, client: AsyncClient, enc_ctx: dict) -> dict:
response = await client.post(
async def encounter(self, auth_client: AsyncClient, enc_ctx: dict) -> dict:
response = await auth_client.post(
f"{RUNS_BASE}/{enc_ctx['run_id']}/encounters",
json={
"routeId": enc_ctx["standalone_id"],
@@ -443,12 +457,14 @@ class TestDeleteEncounter:
return response.json()
async def test_deletes_encounter(
self, client: AsyncClient, encounter: dict, enc_ctx: dict
self, auth_client: AsyncClient, encounter: dict, enc_ctx: dict
):
assert (await client.delete(f"{ENC_BASE}/{encounter['id']}")).status_code == 204
assert (
await auth_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()
detail = (await auth_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
async def test_not_found_returns_404(self, auth_client: AsyncClient):
assert (await auth_client.delete(f"{ENC_BASE}/9999")).status_code == 404