- Sign-in: centred ЕМИАС card without the sidebar; anonymous entry asks «Депрессия / ПТСР».
- Stage 1 is the same for everyone and follows the fixed order of the MVP questionnaire:
PHQ-2 → trauma filter/TSQ → RUS-AUDIT-S → C-SSRS → context; stage 2 only where stage 1
is positive. Starts without a profile.
- After stage 1 the patient sees a preliminary result (screening, not a diagnosis), stored
with the record; it switches on the diary modules (depression / PTSD).
- Stage 2 diary: morning (sleep, sleep quality, alcohol) and evening (mood, medication,
3 PHQ-9 items in rotation, short PCL-5); C-SSRS since last contact only under risk;
flags on weekly means against the personal norm; scheduled full scales with change
(remission, response, worsening).
- Questionnaire log: GET /me/{token}/results and the «Мои результаты» screen.
- Questionnaires centred without the sidebar and without scale names; «Продолжить позже»
goes home; confirmations are toasts; free-text comments are optional.
- Demo policy config/questionnaire_demo.yaml (INTAKE_DEMO only, not a clinical approval).
ADR-012.
Refs #77
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
216 lines
11 KiB
Python
216 lines
11 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
|
|
|
|
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_nothing(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:
|
|
assert await repo.get_profile(s, first["token"]) is None
|
|
|
|
|
|
async def test_anonymous_focus_picks_the_module_without_a_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 not profile.doctor_id
|
|
assert (await client.get(f"/api/v1/me/{token}/today")).json()["has_doctor"] is False
|
|
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})
|
|
assert res.json() == {"token": persona.token, "mode": "emias", "display_name": persona.display_name}
|
|
async with session.get_clinical_session() as s:
|
|
assert (await repo.get_profile(s, persona.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 False 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:
|
|
persona = fixtures.DEMO_PERSONAS[0].token
|
|
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
|
|
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")
|
|
persona = fixtures.DEMO_PERSONAS[0].token
|
|
await client.post("/api/v1/auth/emias/demo", json={"persona_token": persona})
|
|
|
|
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 PHQ-2, in the order of the MVP questionnaire.
|
|
assert any(b.startswith("iq:") and ":phq9_1:" 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_a_profile(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
# An anonymous token unknown to this database (e.g. after a reset) has no profile and no modules.
|
|
monkeypatch.setenv("INTAKE_DEMO", "1")
|
|
token = "c" * 32
|
|
|
|
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(":phq9_1:" in b for b in await press("age:adult"))
|