Files
PTSRaiAgent/tests/test_api_patient_web.py
T
ElebobaandClaude Opus 5.5 31585c7f35
CI / ruff · pytest (pull_request) Successful in 2m41s
feat(bot): every free-text message gets the agent's answer; every demo entry is a private copy
- Idle chat: all messages go to the agent (greetings, side-effect and substitute questions included); check-in
  signals are still recorded silently, quit intent keeps its rule reaction. Seen live: «какие будут побочки»
  was logged as a complaint («Спасибо, отметил»), «привет» got a canned phrase.
- Agent prompt: warm short replies to small talk; substitutes → only the doctor changes the drug.
- ЕМИАС demo persona button creates a private copy too (starting from consent + questionnaires, subject to
  the screening switch); the web app drops a stored session of the old shared demo account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 22:27:26 +03:00

292 lines
15 KiB
Python

"""Patient web app API: ЕМИАС (demo) and anonymous sign-in, crisis resources, hand-off to the dialog engine."""
from collections.abc import AsyncIterator
from datetime import datetime, time, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from app.api import fixtures
from app.core import clock
from app.db import identity_repo, repo, session
from app.db.bootstrap import init_db
from app.db.clinical import Dose, Prescription
from app.main import app
from app.services.anonymous_care import doctor_id as anonymous_doctor
MEM = "sqlite+aiosqlite:///:memory:"
TOKEN = "b" * 32
@pytest.fixture
async def client() -> AsyncIterator[AsyncClient]:
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
yield c
await session.configure()
async def _patient_with_code(code: str) -> None:
async with session.get_identity_session() as s:
await identity_repo.create_patient(
s,
token=TOKEN,
display_name="Синтетический П.",
snils="000-000-000 00",
clinic_id="clinic-1",
doctor_id="doc-1",
now=clock.now(),
)
await identity_repo.create_connect_code(s, code=code, token=TOKEN, doctor_id="doc-1", now=clock.now())
async def test_anonymous_tokens_are_fresh_and_store_no_personal_data(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()
second = (await client.post("/api/v1/auth/anonymous")).json()
assert first["mode"] == "anonymous" and first["display_name"] is None
assert len(first["token"]) == 32 and first["token"] != second["token"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, first["token"]) is None
async with session.get_clinical_session() as s:
# The care team sees a pseudonymous profile without an identity record.
profile = await repo.get_profile(s, first["token"])
assert profile.doctor_id == anonymous_doctor() and profile.modules == []
async def test_anonymous_focus_does_not_remove_access_to_doctor(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous", json={"focus": "ptsd"})).json()["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, token)
assert profile.modules == ["veteran"] and profile.doctor_id == anonymous_doctor()
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is True
assert (await client.post("/api/v1/auth/anonymous", json={"focus": "flu"})).status_code == 422
async def test_anonymous_token_starts_the_engine_dialog(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
replies = (await client.post(f"/api/v1/dialog/{token}/events", json={"type": "start"})).json()["replies"]
buttons = [b["data"] for r in replies for row in r["buttons"] for b in row]
assert "consent:yes" in buttons and "consent:no" in buttons
async def test_emias_code_is_one_time_and_attaches_doctor(client: AsyncClient) -> None:
await _patient_with_code("DEMO-0042")
ok = await client.post("/api/v1/auth/emias", json={"code": " demo-0042 "})
assert ok.status_code == 200 and ok.json() == {"token": TOKEN, "mode": "emias", "display_name": None}
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "doc-1"
again = await client.post("/api/v1/auth/emias", json={"code": "DEMO-0042"})
assert again.status_code == 401
async def test_emias_rejects_unknown_code(client: AsyncClient) -> None:
assert (await client.post("/api/v1/auth/emias", json={"code": "NOPE"})).status_code == 401
assert (await client.post("/api/v1/auth/emias", json={"code": ""})).status_code == 422
async def test_emias_demo_persona(client: AsyncClient) -> None:
persona = fixtures.DEMO_PERSONAS[0]
res = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
# Every visitor gets a private copy of the persona, never the shared account.
assert res["token"].startswith(persona.token + "-") and res["mode"] == "emias"
assert res["display_name"] == persona.display_name
again = (await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona.token})).json()
assert again["token"] != res["token"]
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, res["token"])).doctor_id == fixtures.DOCTOR_ID
missing = await client.post("/api/v1/auth/emias/demo", json={"persona_token": "tok-nobody"})
assert missing.status_code == 404
async def test_today_plan_and_week_marks(client: AsyncClient) -> None:
now = clock.now()
today = now.date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", next_visit=today + timedelta(days=5))
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 мг", schedule={"times": ["09:00", "21:00"]})
s.add(rx)
await s.flush()
morning = datetime.combine(today, time(9, 0), now.tzinfo)
s.add_all(
[
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning, status="taken"),
Dose(token=TOKEN, prescription_id=rx.id, planned_at=morning + timedelta(hours=12)),
]
)
await repo.upsert_checkin(s, token=TOKEN, day=today, now=now, mood=6)
body = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()
assert body["has_doctor"] and body["checkin_done"]
assert body["next_visit"] == (today + timedelta(days=5)).isoformat()
assert [(d["time"], d["status"]) for d in body["doses"]] == [("09:00", "taken"), ("21:00", "pending")]
week = body["prescriptions"][0]["week"]
assert len(week) == 7 and next(w for w in week if w["day"] == today.isoformat())["status"] == "pending"
async def test_today_for_anonymous_is_empty(client: AsyncClient) -> None:
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
body = (await client.get(f"/api/v1/me/{token}/today")).json()
assert body["has_doctor"] is True and body["doses"] == [] and body["prescriptions"] == []
async def test_crisis_resources_come_from_config(client: AsyncClient) -> None:
phones = [r["phone"] for r in (await client.get("/api/v1/crisis/resources")).json()]
assert "112" in phones
async def test_today_links_prescription_to_reference_and_therapy_day(client: AsyncClient) -> None:
today = clock.now().date()
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1", modules=["veteran"])
s.add(Prescription(token=TOKEN, drug="Сертралин", dose="50 мг", schedule={}, start_date=today - timedelta(13)))
rx = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["indication"] == "ptsd" and rx["therapy_day"] == 14
async def test_demo_persona_week_mirrors_dashboard_story(client: AsyncClient) -> None:
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
rx = (await client.get(f"/api/v1/me/{persona}/today")).json()["prescriptions"][0]
assert rx["ref_id"] == "sertraline" and rx["therapy_day"] > 1
async def test_questionnaires_are_off_without_release_or_demo_flag(
client: AsyncClient, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.delenv("INTAKE_DEMO", raising=False)
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="doc-1")
q = (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["questionnaires"]
assert q == {"enabled": False, "intake": "none", "diary_done": False, "diary_next": "morning"}
async def test_demo_flag_opens_intake_in_the_engine(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "1")
auth = await client.post("/api/v1/auth/emias/demo", json={"persona_token": fixtures.DEMO_PERSONAS[0].token})
persona = auth.json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{persona}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
await press()
await press("consent:yes")
buttons = await press("age:adult")
# Stage 1 starts with the red-flag block, then the order of the MVP questionnaire.
assert any(b.startswith("iq:") and ":rf_psychosis:" in b for b in buttons)
assert "intake:help" in buttons and "intake:pause" in buttons
q = (await client.get(f"/api/v1/me/{persona}/today")).json()["questionnaires"]
assert q["enabled"] and q["intake"] == "active"
async def test_instrument_catalog_describes_intake_items(client: AsyncClient) -> None:
body = (await client.get("/api/v1/instruments/catalog")).json()
phq = body["items"]["phq9_3"]
assert phq["form"] == "phq9" and phq["index"] == 3 and phq["total"] == 9 and not phq["adaptive"]
assert body["items"]["cssrs_q1"]["adaptive"] and body["forms"]["cssrs"]["section"] == "safety"
assert body["items"]["sleep_hours"]["numeric"] and body["items"]["alcohol_portions"]["numeric"]
assert body["items"]["dphq_9"]["form"] == "diary" and body["items"]["dpcl_18"]["form"] == "diary"
assert body["forms"]["pcl5"]["recall_period"] and body["forms"]["gad7"]["section"] == "scale"
drinks = body["standard_drinks"]
assert len(drinks["header"]) == 4 and len(drinks["rows"]) == 5 and all(len(c) == 4 for _, c in drinks["rows"])
async def test_meds_reference_is_sourced_and_hides_rule_params(client: AsyncClient) -> None:
body = (await client.get("/api/v1/meds/reference")).json()
assert "rules" not in body and body["texts"]["dont_stop_alone"]
drugs = {d["id"]: d for d in body["drugs"]}
sertraline = drugs["sertraline"]
assert sertraline["indications"]["depression"]["max_mg"] == 200
assert (
sertraline["indications"]["ptsd"]["min_mg"] == 25 and "КР ПТСР" in sertraline["indications"]["ptsd"]["source"]
)
assert drugs["diazepam"]["max_course_days"] == 28 and drugs["alprazolam"]["max_course_days"] == 90
assert all(ind["source"] for d in body["drugs"] for ind in d["indications"].values())
async def test_stage_one_starts_without_modules(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
# Stage 1 is the same for everyone: it does not wait for a module (it sets them itself).
monkeypatch.setenv("INTAKE_DEMO", "1")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
async def press(button: str | None = None) -> list[str]:
event = {"type": "button", "button": button} if button else {"type": "start"}
replies = (await client.post(f"/api/v1/dialog/{token}/events", json=event)).json()["replies"]
return [b["data"] for r in replies for row in r["buttons"] for b in row]
assert (await client.get(f"/api/v1/me/{token}/today")).json()["questionnaires"]["enabled"]
await press()
await press("consent:yes")
assert any(":rf_psychosis:" in b for b in await press("age:adult"))
async def test_patient_app_has_its_own_demo_personas(client: AsyncClient) -> None:
body = (await client.get("/api/v1/patient/demo-personas")).json()
assert [p["token"] for p in body] == [p.token for p in fixtures.DEMO_PERSONAS]
async def test_anonymous_is_visible_only_to_assigned_doctor(client: AsyncClient) -> None:
first = (await client.post("/api/v1/auth/anonymous")).json()["token"]
second = (await client.post("/api/v1/auth/anonymous")).json()["token"]
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
names = {p["token"]: p["display_name"] for p in items}
assert names[first].startswith("Пациент ") and names[first] != names[second]
assert first not in names[first]
own = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": anonymous_doctor()})
assert own.status_code == 200 and own.json()["patient"]["display_name"] == names[first]
denied = await client.get(f"/api/v1/patients/{first}", params={"doctor_id": "another-doctor"})
assert denied.status_code == 403
assert (await client.get("/api/v1/doctors/another-doctor/patients")).json() == []
async def test_old_anonymous_profile_requires_explicit_care_opt_in(client: AsyncClient) -> None:
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="", modules=["veteran"])
# Merely opening Today must not reveal an old private profile to a doctor.
assert not (await client.get(f"/api/v1/me/{TOKEN}/today")).json()["has_doctor"]
assert (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json() == []
for _ in range(2):
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).json() == {"has_doctor": True}
async with session.get_clinical_session() as s:
p = await repo.get_profile(s, TOKEN)
assert p.doctor_id == anonymous_doctor() and p.modules == ["veteran"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, TOKEN) is None
async def test_connect_care_does_not_reassign_or_create_patients(client: AsyncClient) -> None:
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 404
async with session.get_clinical_session() as s:
await repo.upsert_profile(s, token=TOKEN, doctor_id="own-doctor")
assert (await client.post(f"/api/v1/me/{TOKEN}/doctor/connect")).status_code == 200
async with session.get_clinical_session() as s:
assert (await repo.get_profile(s, TOKEN)).doctor_id == "own-doctor"
async def test_screening_capability_respects_release_gate(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": False}
monkeypatch.setenv("INTAKE_DEMO", "1")
assert (await client.get("/api/v1/patient/capabilities")).json() == {"primary_screening": True}
async def test_anonymous_contact_request_reaches_doctor(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setenv("INTAKE_DEMO", "0")
token = (await client.post("/api/v1/auth/anonymous")).json()["token"]
for event in [
{"type": "start"},
{"type": "button", "button": "consent:yes"},
{"type": "button", "button": "age:adult"},
{"type": "button", "button": "menu:contact_doctor"},
]:
response = await client.post(f"/api/v1/dialog/{token}/events", json=event)
assert response.status_code == 200
items = (await client.get(f"/api/v1/doctors/{anonymous_doctor()}/patients")).json()
assert items[0]["last_event"] == "Пациент просит врача связаться"
card = (await client.get(f"/api/v1/patients/{token}", params={"doctor_id": anonymous_doctor()})).json()
assert card["patient"]["last_event"] == items[0]["last_event"]
async with session.get_identity_session() as s:
assert await identity_repo.get_patient(s, token) is None