Files
PTSRaiAgent/tests/test_api_doctor.py
ElebobaandClaude Opus 5.5 25728c4b31
CI / ruff · pytest (pull_request) Successful in 1m25s
feat(backend): API stage 1 on real data instead of fixtures
Doctor list/card/alerts/ack/connect, dialog events/outbox/history and demo
controls now read the identity and clinical DBs. Rule-based discontinuation
risk index, trend, adherence; every doctor read is audited.

Refs #12

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 12:34:40 +03:00

289 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""API #12: doctor dashboard on real DB data — list, card + audit, 403, alerts, ack → crisis ends, connect, no PII."""
from collections.abc import AsyncIterator
from datetime import datetime, timedelta
from types import SimpleNamespace
import httpx
import pytest
from sqlalchemy import select
from app.api import doctor_views
from app.config import Settings
from app.core import clock
from app.core.privacy import pseudo_id
from app.core.types import AlertLevel
from app.db import identity_repo, repo, session
from app.db.audit import AuditLog
from app.db.bootstrap import init_db
from app.db.clinical import Dose, PatientProfile, Prescription, SafetyPlan
from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations
from app.main import app
MEM = "sqlite+aiosqlite:///:memory:"
DOCTOR = "doc-smirnova"
OTHER = "doc-other"
DAY0 = datetime(2026, 10, 5, 9, 0)
# Synthetic patients: token suffix → (name, snils, phone, doctor)
ANDREY, DMITRY, OLGA, PAVEL, IRINA, STRANGER = (c * 32 for c in "abcdef")
PEOPLE = {
ANDREY: ("Андрей С.", "11122233344", "+79990000001", DOCTOR),
DMITRY: ("Дмитрий В.", "22233344455", "+79990000002", DOCTOR),
OLGA: ("Ольга К.", "33344455566", "+79990000003", DOCTOR),
PAVEL: ("Павел Н.", "44455566677", "+79990000004", DOCTOR),
IRINA: ("Ирина М.", "55566677788", "+79990000005", DOCTOR),
STRANGER: ("Чужой П.", "66677788899", "+79990000006", OTHER),
}
@pytest.fixture
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
monkeypatch.setattr(clock, "get_settings", lambda: Settings(_env_file=None, demo_clock=True))
monkeypatch.setattr(integrations, "notifier", MemoryNotifier())
monkeypatch.setattr(integrations, "outbox", MemoryOutbox())
clock.set_now(DAY0 + timedelta(days=13))
await session.configure(identity_url=MEM, clinical_url=MEM)
await init_db()
await seed()
# No lifespan: the scheduler and the Telegram bot are not started.
async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://test") as c:
yield c
clock.register_persistence(None, None)
clock.reset()
await session.configure()
async def seed() -> None:
now = clock.now()
start = (DAY0).date()
async with session.get_identity_session() as ids:
for token, (name, snils, phone, doctor) in PEOPLE.items():
await identity_repo.create_patient(
ids,
token=token,
display_name=name,
snils=snils,
phone=phone,
clinic_id="clinic-1",
doctor_id=doctor,
now=now,
birth_year=1990,
sex="m",
)
async with session.get_clinical_session() as s:
for token, (_, _, _, doctor) in PEOPLE.items():
await repo.upsert_profile(
s, token=token, doctor_id=doctor, modules=["veteran"], therapy_start=start, birth_year=1990, sex="m"
)
s.add(Prescription(token=token, drug="сертралин", dose="50 мг", schedule={"times": ["08:00"]}))
await s.flush()
rx = {r.token: r.id for r in (await s.scalars(select(Prescription))).all()}
# Andrey: acute alert (red), worsening PHQ-9, unscheduled PHQ-9, low mood streak, alcohol, a safety plan.
await repo.add_alert(s, token=ANDREY, level=AlertLevel.ACUTE, code="phq9_item9", reason="PHQ-9 п.9", ts=now)
await repo.add_scale_result(
s, token=ANDREY, scale="phq9", total=12, severity="moderate", complete=True, ts=now - timedelta(days=7)
)
await repo.add_scale_result(
s,
token=ANDREY,
scale="phq9",
total=18,
severity="mod_severe",
complete=True,
ts=now - timedelta(hours=2),
scheduled=False,
item9=2,
)
await repo.add_scale_result(
s, token=ANDREY, scale="pcl5", total=44, severity="-", complete=True, ts=now - timedelta(days=13)
)
for i, (mood, sleep, alcohol) in enumerate([(3, "<4", "more"), (2, "4-6", "1-2"), (2, "<4", "none")]):
await repo.upsert_checkin(
s,
token=ANDREY,
day=now.date() - timedelta(days=2 - i),
now=now,
mood=mood,
sleep_bucket=sleep,
alcohol_bucket=alcohol,
med_taken=True,
)
s.add(
SafetyPlan(
token=ANDREY, steps=["Позвонить брату", {"who": "Кризисная линия", "phone": "8-800"}], updated_at=now
)
)
# Dmitry: high alert (yellow). Olga: info only (grey).
await repo.add_alert(s, token=DMITRY, level=AlertLevel.HIGH, code="pcl5_high", reason="PCL-5 ≥ 50", ts=now)
await repo.add_alert(s, token=OLGA, level=AlertLevel.INFO, code="no_contact_3d", reason="Молчит 3 дня", ts=now)
# Pavel and Irina: green; Pavel missed doses + voiced quit intent → higher risk, sorted first among greens.
for d in range(7):
planned = now - timedelta(days=d, hours=1)
s.add(
Dose(token=PAVEL, prescription_id=rx[PAVEL], planned_at=planned, status="missed" if d < 3 else "taken")
)
s.add(Dose(token=IRINA, prescription_id=rx[IRINA], planned_at=planned, status="taken"))
await repo.upsert_checkin(s, token=PAVEL, day=now.date(), now=now, quit_intent=True, mood=5)
# Foreign doctor's patient with an open alert: must never show up for DOCTOR.
await repo.add_alert(s, token=STRANGER, level=AlertLevel.ACUTE, code="cssrs_high", reason="C-SSRS", ts=now)
await repo.set_dialog_state(
s,
token=ANDREY,
state="crisis",
data={"consent": True, "adult": True, "crisis": {"open": True, "opened_at": now.isoformat()}},
now=now,
)
async def audit_rows(action: str) -> list[AuditLog]:
async with session.get_clinical_session() as s:
return list((await s.scalars(select(AuditLog).where(AuditLog.action == action))).all())
async def test_list_order_colours_and_metrics(client: httpx.AsyncClient) -> None:
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert [r["token"] for r in rows] == [ANDREY, DMITRY, OLGA, PAVEL, IRINA]
assert [r["status_color"] for r in rows] == ["red", "yellow", "grey", "green", "green"]
andrey, pavel, irina = rows[0], rows[3], rows[4]
assert andrey["display_name"] == "Андрей С." and andrey["age"] == 36
assert andrey["top_alert_level"] == "acute" and andrey["open_alerts"] == 1
assert (andrey["phq9_last"], andrey["pcl5_last"], andrey["trend"]) == (18, 44, "up")
assert andrey["drug"] == "сертралин 50 мг" and andrey["therapy_day"] == 13
assert andrey["last_event"] == "PHQ-9 п.9" # the alert is newer than the last scale
assert rows[1]["last_event"] == "PCL-5 ≥ 50"
factors = " ".join(andrey["discontinuation_risk"]["factors"])
assert "настроение ≤ 3 3 дн. подряд" in factors and "алкоголь 2 дн. из 7" in factors
assert pavel["adherence_7d"] == pytest.approx(4 / 7, abs=0.01)
assert irina["adherence_7d"] == 1.0 and irina["discontinuation_risk"]["score"] == 0
assert pavel["discontinuation_risk"]["score"] == 30 + 25 # 3 missed days + quit intent
assert pavel["discontinuation_risk"]["level"] == "medium"
assert len(await audit_rows("view_patient_list")) == 1
async def test_card_writes_audit_and_has_series(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})
assert r.status_code == 200
card = r.json()
metrics = {(p["metric"], p["value"], p["scheduled"]) for p in card["series"]}
assert ("phq9", 18.0, False) in metrics and ("phq9", 12.0, True) in metrics
assert ("sleep_hours", 3.0, True) in metrics and ("sleep_hours", 5.0, True) in metrics
assert ("alcohol", 2.0, True) in metrics and ("mood", 2.0, True) in metrics
assert card["safety_plan"] == ["Позвонить брату", "Кризисная линия: 8-800"]
assert card["alerts"][0]["patient_name"] == "Андрей С." and card["alerts"][0]["color"] == "red"
assert card["prescriptions"][0]["schedule"] == {"times": ["08:00"]}
assert card["summary"]
rows = await audit_rows("view_patient")
assert len(rows) == 1 and rows[0].actor == DOCTOR and rows[0].token == ANDREY
async def test_card_forbidden_for_foreign_doctor(client: httpx.AsyncClient) -> None:
r = await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": OTHER})
assert r.status_code == 403
assert await audit_rows("view_patient") == []
assert len(await audit_rows("view_patient_denied")) == 1
assert (await client.get("/api/v1/patients/" + "0" * 32, params={"doctor_id": DOCTOR})).status_code == 404
async def test_alerts_feed_since_id_and_open_only(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).json()
assert {a["token"] for a in alerts} == {ANDREY, DMITRY, OLGA} # no foreign patient
assert all(a["patient_name"] for a in alerts)
top = max(a["id"] for a in alerts)
assert (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"since_id": top})).json() == []
assert len(await audit_rows("view_alerts")) == 1 # empty poll is not a disclosure
async def test_ack_acute_ends_crisis(client: httpx.AsyncClient) -> None:
alerts = (await client.get(f"/api/v1/doctors/{DOCTOR}/alerts", params={"open_only": True})).json()
acute = next(a for a in alerts if a["token"] == ANDREY)
assert (await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": OTHER})).status_code == 403
r = await client.post(f"/api/v1/alerts/{acute['id']}/ack", json={"doctor_id": DOCTOR})
assert r.status_code == 200
out = r.json()
assert out["acked_by"] == DOCTOR and out["acked_at"] and out["patient_name"] == "Андрей С."
async with session.get_clinical_session() as s:
state = await repo.get_dialog_state(s, ANDREY)
profile = await s.get(PatientProfile, ANDREY)
assert state is not None and state.state == "idle" and state.data["crisis"]["open"] is False
assert profile is not None and profile.status_color == "green"
assert isinstance(integrations.outbox, MemoryOutbox)
assert integrations.outbox.drain(ANDREY) # "crisis closed" message waits for the console
assert len(await audit_rows("ack_alert")) == 1
rows = (await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).json()
assert next(x for x in rows if x["token"] == ANDREY)["status_color"] == "green"
async def test_connect_creates_patient_and_redeemable_code(client: httpx.AsyncClient) -> None:
body = {
"doctor_id": DOCTOR,
"display_name": "Сергей Т.",
"birth_year": 1988,
"sex": "m",
"snils": "123-456-789 01",
"drug": "сертралин",
"dose": "50 мг",
"times": ["08:00", "20:00"],
"instructions": "после еды",
"modules": ["veteran", "depression"],
}
r = await client.post("/api/v1/patients/connect", json=body)
assert r.status_code == 200
out = r.json()
assert out["token"] == pseudo_id("12345678901")
assert out["deep_link"] == f"/console?code={out['code']}"
assert "12345678901" not in r.text and "123-456-789" not in r.text
async with session.get_identity_session() as ids:
assert await identity_repo.redeem_connect_code(ids, code=out["code"], now=clock.now()) == out["token"]
async with session.get_clinical_session() as s:
profile = await repo.get_profile(s, out["token"])
rx = await s.scalar(select(Prescription).where(Prescription.token == out["token"]))
assert profile is not None and profile.therapy_start == clock.today() and profile.doctor_id == DOCTOR
assert rx is not None and rx.schedule["times"] == ["08:00", "20:00"]
assert len(await audit_rows("connect_patient")) == 1
card = (await client.get(f"/api/v1/patients/{out['token']}", params={"doctor_id": DOCTOR})).json()
assert card["prescriptions"][0]["instructions"] == "после еды"
assert card["patient"]["therapy_day"] == 0 and card["patient"]["status_color"] == "green"
assert (await client.post("/api/v1/patients/connect", json=body)).status_code == 409
async def test_no_pii_in_doctor_responses(client: httpx.AsyncClient) -> None:
texts = [
(await client.get(f"/api/v1/doctors/{DOCTOR}/patients")).text,
(await client.get(f"/api/v1/patients/{ANDREY}", params={"doctor_id": DOCTOR})).text,
(await client.get(f"/api/v1/doctors/{DOCTOR}/alerts")).text,
]
for _, snils, phone, _ in PEOPLE.values():
for text in texts:
assert snils not in text and phone not in text and phone[1:] not in text
def test_risk_index_all_factors_capped() -> None:
now = datetime(2026, 10, 18, 12, 0).astimezone()
days = [now.date() - timedelta(days=i) for i in range(4)]
checkins = [
SimpleNamespace(
day=d, mood=2, med_taken=False, side_effects=["nausea"], quit_intent=True, alcohol_bucket="more"
)
for d in days
]
data = doctor_views.PatientData(
profile=SimpleNamespace(therapy_start=days[-1] - timedelta(days=10)), # type: ignore[arg-type]
checkins=checkins, # type: ignore[arg-type]
results=[],
doses=[],
prescriptions=[],
alerts=[SimpleNamespace(code="quit_intent", acked_at=None)], # type: ignore[list-item]
)
risk = doctor_views.discontinuation_risk(data, now)
assert risk.score == 100 and risk.level == "high"
assert risk.factors == [
"пропуски приёма 4/7 дн.",
"побочные эффекты 4 дн. подряд",
"хотел бросить (день 13)",
"не разобран сигнал «хочет бросить»",
"настроение ≤ 3 4 дн. подряд",
"алкоголь 4 дн. из 7",
]