"""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"))