feat: infer genlocke visibility from first leg's run
Genlockes now inherit visibility from their first leg's run: - Private runs make genlockes hidden from public listings - All genlocke read endpoints now accept optional auth - Returns 404 for private genlockes to non-owners Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,13 @@
|
||||
"""Integration tests for the Genlockes & Bosses API."""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.auth import AuthUser, get_current_user
|
||||
from app.main import app
|
||||
from app.models.game import Game
|
||||
from app.models.nuzlocke_run import NuzlockeRun, RunVisibility
|
||||
from app.models.pokemon import Pokemon
|
||||
from app.models.route import Route
|
||||
from app.models.version_group import VersionGroup
|
||||
@@ -55,7 +58,9 @@ async def games_ctx(db_session: AsyncSession) -> dict:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def ctx(db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict) -> dict:
|
||||
async def ctx(
|
||||
db_session: AsyncSession, admin_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)
|
||||
@@ -116,6 +121,178 @@ class TestListGenlockes:
|
||||
assert "Test Genlocke" in names
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Genlockes — visibility (inferred from first leg's run)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestGenlockeVisibility:
|
||||
"""Test that genlocke visibility is inferred from the first leg's run."""
|
||||
|
||||
@pytest.fixture
|
||||
async def private_genlocke_ctx(
|
||||
self, db_session: AsyncSession, admin_client: AsyncClient, games_ctx: dict
|
||||
) -> dict:
|
||||
"""Create a genlocke and make its first leg's run private."""
|
||||
r = await admin_client.post(
|
||||
GENLOCKES_BASE,
|
||||
json={
|
||||
"name": "Private 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"]
|
||||
|
||||
# Make the run private
|
||||
run = await db_session.get(NuzlockeRun, run_id)
|
||||
assert run is not None
|
||||
run.visibility = RunVisibility.PRIVATE
|
||||
await db_session.commit()
|
||||
|
||||
return {
|
||||
**games_ctx,
|
||||
"genlocke_id": genlocke["id"],
|
||||
"run_id": run_id,
|
||||
"owner_id": str(run.owner_id),
|
||||
}
|
||||
|
||||
async def test_private_genlocke_hidden_from_unauthenticated_list(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should not see private genlockes in the list."""
|
||||
# Temporarily remove auth override to simulate unauthenticated request
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(GENLOCKES_BASE)
|
||||
assert response.status_code == 200
|
||||
names = [g["name"] for g in response.json()]
|
||||
assert "Private Genlocke" not in names
|
||||
finally:
|
||||
pass
|
||||
|
||||
async def test_private_genlocke_visible_to_owner_in_list(
|
||||
self, admin_client: AsyncClient, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Owner should still see their private genlocke in the list."""
|
||||
response = await admin_client.get(GENLOCKES_BASE)
|
||||
assert response.status_code == 200
|
||||
names = [g["name"] for g in response.json()]
|
||||
assert "Private Genlocke" in names
|
||||
|
||||
async def test_private_genlocke_404_for_unauthenticated_get(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke details."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_accessible_to_owner(
|
||||
self, admin_client: AsyncClient, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Owner should still be able to access their private genlocke."""
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["name"] == "Private Genlocke"
|
||||
|
||||
async def test_private_genlocke_graveyard_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke graveyard."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/graveyard"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_lineages_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke lineages."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/lineages"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_survivors_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private genlocke survivors."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/legs/1/survivors"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_retired_families_404_for_unauthenticated(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""Unauthenticated users should get 404 for private retired-families."""
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as unauth_client:
|
||||
response = await unauth_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}/retired-families"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
async def test_private_genlocke_404_for_different_user(
|
||||
self, db_session: AsyncSession, private_genlocke_ctx: dict
|
||||
):
|
||||
"""A different authenticated user should get 404 for private genlockes."""
|
||||
# Create a different user's auth
|
||||
different_user = AuthUser(
|
||||
id="00000000-0000-4000-a000-000000000099",
|
||||
email="other@example.com",
|
||||
role="authenticated",
|
||||
)
|
||||
|
||||
def _override():
|
||||
return different_user
|
||||
|
||||
app.dependency_overrides[get_current_user] = _override
|
||||
try:
|
||||
async with AsyncClient(
|
||||
transport=ASGITransport(app=app), base_url="http://test"
|
||||
) as other_client:
|
||||
response = await other_client.get(
|
||||
f"{GENLOCKES_BASE}/{private_genlocke_ctx['genlocke_id']}"
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
# Also check list
|
||||
list_response = await other_client.get(GENLOCKES_BASE)
|
||||
assert list_response.status_code == 200
|
||||
names = [g["name"] for g in list_response.json()]
|
||||
assert "Private Genlocke" not in names
|
||||
finally:
|
||||
app.dependency_overrides.pop(get_current_user, None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Genlockes — create
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -259,14 +436,18 @@ class TestGenlockeLegs:
|
||||
|
||||
|
||||
class TestAdvanceLeg:
|
||||
async def test_uncompleted_run_returns_400(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_uncompleted_run_returns_400(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
"""Cannot advance when leg 1's run is still active."""
|
||||
response = await admin_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, admin_client: AsyncClient, games_ctx: dict):
|
||||
async def test_no_next_leg_returns_400(
|
||||
self, admin_client: AsyncClient, games_ctx: dict
|
||||
):
|
||||
"""A single-leg genlocke cannot be advanced."""
|
||||
r = await admin_client.post(
|
||||
GENLOCKES_BASE,
|
||||
@@ -283,7 +464,9 @@ class TestAdvanceLeg:
|
||||
|
||||
async def test_advances_to_next_leg(self, admin_client: AsyncClient, ctx: dict):
|
||||
"""Completing the current run allows advancing to the next leg."""
|
||||
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
|
||||
await admin_client.patch(
|
||||
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
|
||||
)
|
||||
|
||||
response = await admin_client.post(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance"
|
||||
@@ -295,7 +478,9 @@ class TestAdvanceLeg:
|
||||
|
||||
async def test_advances_with_transfers(self, admin_client: AsyncClient, ctx: dict):
|
||||
"""Advancing with transfer_encounter_ids creates egg encounters in the next leg."""
|
||||
await admin_client.patch(f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"})
|
||||
await admin_client.patch(
|
||||
f"{RUNS_BASE}/{ctx['run_id']}", json={"status": "completed"}
|
||||
)
|
||||
|
||||
response = await admin_client.post(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/1/advance",
|
||||
@@ -319,30 +504,40 @@ class TestAdvanceLeg:
|
||||
|
||||
class TestGenlockeGraveyard:
|
||||
async def test_returns_empty_graveyard(self, admin_client: AsyncClient, ctx: dict):
|
||||
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/graveyard")
|
||||
response = await admin_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, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")).status_code == 404
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/9999/graveyard")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
class TestGenlockeLineages:
|
||||
async def test_returns_empty_lineages(self, admin_client: AsyncClient, ctx: dict):
|
||||
response = await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/lineages")
|
||||
response = await admin_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, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")).status_code == 404
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/9999/lineages")
|
||||
).status_code == 404
|
||||
|
||||
|
||||
class TestGenlockeRetiredFamilies:
|
||||
async def test_returns_empty_retired_families(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_returns_empty_retired_families(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
response = await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/retired-families"
|
||||
)
|
||||
@@ -365,9 +560,13 @@ class TestLegSurvivors:
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
|
||||
async def test_leg_not_found_returns_404(self, admin_client: AsyncClient, ctx: dict):
|
||||
async def test_leg_not_found_returns_404(
|
||||
self, admin_client: AsyncClient, ctx: dict
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors")
|
||||
await admin_client.get(
|
||||
f"{GENLOCKES_BASE}/{ctx['genlocke_id']}/legs/99/survivors"
|
||||
)
|
||||
).status_code == 404
|
||||
|
||||
|
||||
@@ -386,7 +585,9 @@ BOSS_PAYLOAD = {
|
||||
|
||||
class TestBossCRUD:
|
||||
async def test_empty_list(self, admin_client: AsyncClient, games_ctx: dict):
|
||||
response = await admin_client.get(f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses")
|
||||
response = await admin_client.get(
|
||||
f"{GAMES_BASE}/{games_ctx['game1_id']}/bosses"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -441,7 +642,9 @@ class TestBossCRUD:
|
||||
async def test_invalid_game_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{GAMES_BASE}/9999/bosses")).status_code == 404
|
||||
|
||||
async def test_game_without_version_group_returns_400(self, admin_client: AsyncClient):
|
||||
async def test_game_without_version_group_returns_400(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
game = (
|
||||
await admin_client.post(
|
||||
GAMES_BASE,
|
||||
@@ -480,7 +683,9 @@ class TestBossResults:
|
||||
return {"boss_id": boss["id"], "run_id": run["id"]}
|
||||
|
||||
async def test_empty_list(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
response = await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
|
||||
response = await admin_client.get(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json() == []
|
||||
|
||||
@@ -495,7 +700,9 @@ class TestBossResults:
|
||||
assert data["attempts"] == 1
|
||||
assert data["completedAt"] is not None
|
||||
|
||||
async def test_upserts_existing_result(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
async def test_upserts_existing_result(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
"""POSTing the same boss twice updates the result (upsert)."""
|
||||
await admin_client.post(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
|
||||
@@ -530,10 +737,16 @@ class TestBossResults:
|
||||
await admin_client.get(f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results")
|
||||
).json() == []
|
||||
|
||||
async def test_invalid_run_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
assert (await admin_client.get(f"{RUNS_BASE}/9999/boss-results")).status_code == 404
|
||||
async def test_invalid_run_returns_404(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{RUNS_BASE}/9999/boss-results")
|
||||
).status_code == 404
|
||||
|
||||
async def test_invalid_boss_returns_404(self, admin_client: AsyncClient, boss_ctx: dict):
|
||||
async def test_invalid_boss_returns_404(
|
||||
self, admin_client: AsyncClient, boss_ctx: dict
|
||||
):
|
||||
response = await admin_client.post(
|
||||
f"{RUNS_BASE}/{boss_ctx['run_id']}/boss-results",
|
||||
json={"bossBattleId": 9999, "result": "won"},
|
||||
@@ -587,8 +800,16 @@ class TestExport:
|
||||
assert response.status_code == 200
|
||||
assert isinstance(response.json(), list)
|
||||
|
||||
async def test_export_game_routes_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")).status_code == 404
|
||||
async def test_export_game_routes_not_found_returns_404(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{EXPORT_BASE}/games/9999/routes")
|
||||
).status_code == 404
|
||||
|
||||
async def test_export_game_bosses_not_found_returns_404(self, admin_client: AsyncClient):
|
||||
assert (await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")).status_code == 404
|
||||
async def test_export_game_bosses_not_found_returns_404(
|
||||
self, admin_client: AsyncClient
|
||||
):
|
||||
assert (
|
||||
await admin_client.get(f"{EXPORT_BASE}/games/9999/bosses")
|
||||
).status_code == 404
|
||||
|
||||
Reference in New Issue
Block a user