import uuid from datetime import datetime, timezone import pytest from sqlalchemy import select from app.models.contact_session import ContactSession from app.models.entity import Entity from app.models.entity_sighting import EntitySighting from app.models.user import User def _make_entity(name="Vesper Wren", rarity="rare", signature="abc123def4567890"): return Entity( name=name, epithet="the Static Widow", persona="A voice worn smooth as sea glass.", rarity_tier=rarity, signature=signature, voice_profile={"voice_id": "lessac", "pitch": -2, "rate": 0.95, "noise": 0.04}, visual_profile={"hue": 265, "form": "wisp"}, sample_quotes=["I am closer than the dial suggests."], contact_count=3, ) @pytest.mark.asyncio async def test_codex_lists_entities(client, db_session): db_session.add(_make_entity()) db_session.add(_make_entity("Hollow Briar", "common", "0123456789abcdef")) await db_session.commit() response = await client.get("/api/codex") assert response.status_code == 200 names = {entity["name"] for entity in response.json()["entities"]} assert names == {"Vesper Wren", "Hollow Briar"} @pytest.mark.asyncio async def test_codex_filters_by_rarity(client, db_session): db_session.add(_make_entity()) db_session.add(_make_entity("Hollow Briar", "common", "0123456789abcdef")) await db_session.commit() response = await client.get("/api/codex?rarity=rare") assert response.status_code == 200 entities = response.json()["entities"] assert len(entities) == 1 assert entities[0]["name"] == "Vesper Wren" @pytest.mark.asyncio async def test_codex_detail_and_404(client, db_session): entity = _make_entity() db_session.add(entity) await db_session.commit() await db_session.refresh(entity) response = await client.get(f"/api/codex/{entity.id}") assert response.status_code == 200 body = response.json() assert body["name"] == "Vesper Wren" assert body["persona"].startswith("A voice") assert body["sightings"] == 0 missing = await client.get(f"/api/codex/{uuid.uuid4()}") assert missing.status_code == 404 @pytest.mark.asyncio async def test_stats_counts_veil_activity(client, db_session): db_session.add(_make_entity()) await db_session.commit() response = await client.get("/api/stats") assert response.status_code == 200 body = response.json() assert body["entities"] == 1 assert body["sessions"] == 0 assert body["utterances"] == 0 assert body["anomalies"] == 0 @pytest.mark.asyncio async def test_codex_is_public_without_auth(client): assert (await client.get("/api/codex")).status_code == 200 assert (await client.get("/api/stats")).status_code == 200 # --- the encounter record: who summoned a spirit, and who else has met it --- # (hunter-profiles wave). The data already existed — every contact is an # EntitySighting row, discovery is Entity.discovered_by. async def _hunter(db_session, username: str, **profile): """A hunter. `profile` fields (display_name, profile_public, …) are set only if the parallel profile migration has landed, so these tests pass either way.""" user = User(username=username, password_hash="x") for key, value in profile.items(): if hasattr(User, key): setattr(user, key, value) db_session.add(user) await db_session.flush() return user async def _contact(db_session, entity, user, seen_at): session = ContactSession(user_id=user.id, entity_id=entity.id, mode="wire") db_session.add(session) await db_session.flush() db_session.add( EntitySighting( entity_id=entity.id, session_id=session.id, user_id=user.id, seen_at=seen_at, ) ) @pytest.mark.asyncio async def test_detail_surfaces_the_discoverer(client, db_session): entity = _make_entity() db_session.add(entity) summoner = await _hunter(db_session, "mora", display_name="Mora Vane") await db_session.flush() entity.discovered_by = summoner.id await _contact( db_session, entity, summoner, datetime(2026, 7, 1, tzinfo=timezone.utc) ) await db_session.commit() body = (await client.get(f"/api/codex/{entity.id}")).json() assert body["discovered_by"] == "mora" assert body["discoverer"]["username"] == "mora" assert body["discoverer"]["times_contacted"] == 1 assert body["discoverer"]["last_seen"] is not None # Public by default, so the dossier may link to their hunter page. assert body["discoverer_public"] is True assert body["discoverer"]["display_name"] in {"mora", "Mora Vane"} @pytest.mark.asyncio async def test_encounter_roster_aggregates_per_hunter(client, db_session): entity = _make_entity() db_session.add(entity) twice = await _hunter(db_session, "reva") once = await _hunter(db_session, "isolde") await _contact(db_session, entity, twice, datetime(2026, 7, 1, tzinfo=timezone.utc)) await _contact(db_session, entity, twice, datetime(2026, 7, 2, tzinfo=timezone.utc)) await _contact(db_session, entity, once, datetime(2026, 7, 9, tzinfo=timezone.utc)) await db_session.commit() body = (await client.get(f"/api/codex/{entity.id}")).json() assert body["sightings"] == 3 # Distinct hunters, not sighting rows. assert body["total_encounters"] == 2 roster = body["encounters"] assert [h["username"] for h in roster] == ["isolde", "reva"] # newest first by_name = {h["username"]: h for h in roster} assert by_name["reva"]["times_contacted"] == 2 assert by_name["isolde"]["times_contacted"] == 1 assert by_name["reva"]["last_seen"].startswith("2026-07-02") @pytest.mark.asyncio async def test_private_hunters_are_counted_but_not_linkable(client, db_session): if not hasattr(User, "profile_public"): pytest.skip("profile_public lands with the profile workstream") entity = _make_entity() db_session.add(entity) hidden = await _hunter(db_session, "nocturne", profile_public=False) seen = await _hunter(db_session, "calla", profile_public=True) await _contact(db_session, entity, hidden, datetime(2026, 7, 3, tzinfo=timezone.utc)) await _contact(db_session, entity, seen, datetime(2026, 7, 4, tzinfo=timezone.utc)) await db_session.commit() body = (await client.get(f"/api/codex/{entity.id}")).json() assert body["total_encounters"] == 2 flags = {h["username"]: h["public"] for h in body["encounters"]} assert flags == {"nocturne": False, "calla": True} @pytest.mark.asyncio async def test_unmet_spirit_has_an_empty_roster(client, db_session): entity = _make_entity() db_session.add(entity) await db_session.commit() await db_session.refresh(entity) body = (await client.get(f"/api/codex/{entity.id}")).json() assert body["encounters"] == [] assert body["total_encounters"] == 0 assert body["discoverer"] is None assert body["discoverer_public"] is False