All five agents died mid-flight (three on session limits, two on 529s), but their worktrees held real work — 17 files. Salvaged everything, wrote the missing pieces, and finished the integration by hand. PROFILES + RANK User gains display_name, bio, gender, avatar_form, avatar_hue and profile_public — all nullable, so every existing row including the guest `wanderer-` accounts stays valid with no backfill. The avatar is procedural (a GhostForm plus a hue, drawn by the same GhostGlyph that renders entities): no uploads means no moderation surface, no EXIF and no blob storage, and an `avatar_url` still slots in later without changing anything. rank.py converts encounters, essence and favor into one "standing" currency and maps it onto six one-word titles. An encounter is worth ten points to ten essence's one, because contact is what the app is about — a seeker who only buys unlocks climbs very slowly. Negative essence and favor floor at zero rather than subtracting, so a bad judgment can never demote you: rank is a record of what you have done. Level 1 costs exactly one encounter, so a new hunter sees the bar move after their first séance. Privacy invariants, verified live rather than assumed: - `email` is returned by GET /api/profile/me and by nothing else. Confirmed against the running server: zero occurrences in both public payloads. - A hidden profile 404s rather than 403s — confirming the account exists would leak exactly what hiding it was meant to prevent. WHISPERS BETWEEN HUNTERS Plain text, no attachments, no editing. Guests can RECEIVE but not send: that gives registering a felt purpose beyond keeping a codex, and closes the obvious spam vector since guest accounts are free and automatic. Verified live: alice→bob delivers, a guest send returns 403, and a third party's conversation list comes back empty — no cross-user leak. Message bodies are rendered as text nodes, never as HTML, and wrap with overflow-wrap:anywhere so a long unbroken string can't blow out the layout. THE ENCOUNTER RECORD The Codex already knew all of this — Entity.discovered_by has always been recorded and every contact was already an entity_sightings row. Nobody ever showed it. Now an entity page names its summoner and lists every hunter who has met it. Hunters who opted out of a public profile are still COUNTED but not linkable: an anonymous contact is still a contact, so a spirit's history stays honest without exposing anyone. Live on production data: Mabel Crump, discovered by Charly, 1 encounter; Charly ranks channeler (level 2) from 5 real sightings — all computed from data that was already sitting there. 385 frontend tests pass; i18n parity holds across both languages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
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
|