Usability wave Workstream C (#5, #7): - manifest.webmanifest with existing 192/512/180 icons, linked in index.html with theme-color aligned to #07070d; no service worker. - GET /api/seances/recent: last 12 of the seeker's own séances with entity, per-kind event counts (one GROUP BY) and up to 3 spirit echoes (one windowed query) — no per-session N+1. - /log Ghost Log page: cards with GhostGlyph, relative in-fiction timestamps, counts and echo lines; LOG link in the séance topbar; en/es i18n parity. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
152 lines
4.7 KiB
Python
152 lines
4.7 KiB
Python
"""Tests for GET /api/seances/recent — the Ghost Log recap
|
|
(usability wave, Workstream C, #7)."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
|
|
import pytest
|
|
|
|
from app.models.contact_session import ContactSession
|
|
from app.models.entity import Entity
|
|
from app.models.event import Event
|
|
|
|
|
|
async def _register(client, username):
|
|
resp = await client.post(
|
|
"/auth/register", json={"username": username, "password": "spookyspooky"}
|
|
)
|
|
assert resp.status_code == 201
|
|
return resp.json()["id"]
|
|
|
|
|
|
async def _login(client, username):
|
|
resp = await client.post(
|
|
"/auth/login", json={"username": username, "password": "spookyspooky"}
|
|
)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def _entity(name, signature):
|
|
return Entity(
|
|
name=name,
|
|
epithet="the Test Wraith",
|
|
rarity_tier="rare",
|
|
signature=signature,
|
|
visual_profile={"hue": 200, "form": "wisp"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_unauthenticated_rejected(client):
|
|
resp = await client.get("/api/seances/recent")
|
|
assert resp.status_code == 401
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_only_own_sessions_newest_first_with_counts_and_echoes(client, db_session):
|
|
my_id = await _register(client, "seeker1")
|
|
other_id = await _register(client, "seeker2")
|
|
await _login(client, "seeker1")
|
|
|
|
entity = _entity("Testnamed", "sig-aaaa")
|
|
db_session.add(entity)
|
|
await db_session.flush()
|
|
|
|
now = datetime.now(timezone.utc)
|
|
older = ContactSession(
|
|
user_id=my_id, entity_id=entity.id, mode="evp", started_at=now - timedelta(hours=2)
|
|
)
|
|
newer = ContactSession(
|
|
user_id=my_id, entity_id=entity.id, mode="wire", started_at=now - timedelta(hours=1)
|
|
)
|
|
theirs = ContactSession(user_id=other_id, mode="ouija", started_at=now)
|
|
db_session.add_all([older, newer, theirs])
|
|
await db_session.flush()
|
|
|
|
# Events on the newer session: 2 questions, 3 anomalies, 4 spirit
|
|
# utterances (only the newest 3 should come back as echoes).
|
|
events = [
|
|
Event(session_id=newer.id, kind="question", text="who's there?"),
|
|
Event(session_id=newer.id, kind="question", text="still there?"),
|
|
Event(session_id=newer.id, kind="anomaly", payload={"score": 1}),
|
|
Event(session_id=newer.id, kind="anomaly", payload={"score": 2}),
|
|
Event(session_id=newer.id, kind="anomaly", payload={"score": 3}),
|
|
]
|
|
for i in range(4):
|
|
events.append(
|
|
Event(
|
|
session_id=newer.id,
|
|
kind="utterance",
|
|
text=f"echo {i}",
|
|
payload={"kind": "reply"},
|
|
created_at=now - timedelta(minutes=30 - i),
|
|
)
|
|
)
|
|
# An utterance on the OTHER user's session must never leak.
|
|
events.append(
|
|
Event(
|
|
session_id=theirs.id,
|
|
kind="utterance",
|
|
text="not yours",
|
|
payload={"kind": "greeting"},
|
|
)
|
|
)
|
|
db_session.add_all(events)
|
|
await db_session.commit()
|
|
|
|
resp = await client.get("/api/seances/recent")
|
|
assert resp.status_code == 200
|
|
seances = resp.json()["seances"]
|
|
|
|
# Only my sessions, newest first.
|
|
assert [s["id"] for s in seances] == [str(newer.id), str(older.id)]
|
|
|
|
first = seances[0]
|
|
assert first["mode"] == "wire"
|
|
assert first["counts"] == {"question": 2, "anomaly": 3, "utterance": 4}
|
|
assert first["echoes"] == ["echo 3", "echo 2", "echo 1"]
|
|
assert first["entity"] == {
|
|
"id": str(entity.id),
|
|
"name": "Testnamed",
|
|
"epithet": "the Test Wraith",
|
|
"rarity": "rare",
|
|
"visual": {"hue": 200, "form": "wisp"},
|
|
}
|
|
|
|
# No cross-user text anywhere in the response.
|
|
assert "not yours" not in resp.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_session_without_entity_does_not_crash(client, db_session):
|
|
my_id = await _register(client, "seeker3")
|
|
await _login(client, "seeker3")
|
|
|
|
session = ContactSession(user_id=my_id, mode="ouija")
|
|
db_session.add(session)
|
|
await db_session.commit()
|
|
|
|
resp = await client.get("/api/seances/recent")
|
|
assert resp.status_code == 200
|
|
seances = resp.json()["seances"]
|
|
assert len(seances) == 1
|
|
assert seances[0]["entity"] is None
|
|
assert seances[0]["counts"] == {}
|
|
assert seances[0]["echoes"] == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_limited_to_twelve(client, db_session):
|
|
my_id = await _register(client, "seeker4")
|
|
await _login(client, "seeker4")
|
|
|
|
now = datetime.now(timezone.utc)
|
|
db_session.add_all(
|
|
ContactSession(user_id=my_id, mode="wire", started_at=now - timedelta(minutes=i))
|
|
for i in range(15)
|
|
)
|
|
await db_session.commit()
|
|
|
|
resp = await client.get("/api/seances/recent")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()["seances"]) == 12
|