CI / ruff · pytest (pull_request) Successful in 1m3s
- app/scheduler/demo.py: run_until(now) replays due and fresh events in time order: dose rows + med_reminder, missed after 2 h without an answer → monitoring, daily_checkin, scale_due by the config/modules.yaml schedules, daily monitoring pass (silence, missed doses) → reach_out/scale_due - idempotent via scheduler_events (unique token+key); stale events are not sent; no contact without consent or during an open crisis - config/scheduler.yaml: times and windows; runs while the questionnaire release is off - POST /demo/clock/advance replays the scheduler up to the new time Refs #13 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
284 lines
13 KiB
Python
284 lines
13 KiB
Python
"""Data model tests on two separate in-memory SQLite databases (identity / clinical).
|
|
|
|
Set TEST_PG_IDENTITY_URL / TEST_PG_CLINICAL_URL to run the same suite against compose Postgres.
|
|
"""
|
|
|
|
import ast
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from collections.abc import AsyncIterator, Callable
|
|
from datetime import UTC, date, datetime, timedelta
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from sqlalchemy import inspect, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.ext.asyncio import AsyncEngine
|
|
|
|
from app.core.types import AlertLevel, ScrubbedText
|
|
from app.db import identity_repo, repo, session
|
|
from app.db.audit import AuditLog, audit
|
|
from app.db.bootstrap import init_db
|
|
from app.db.clinical import ClinicalBase, DailyCheckin, Dose, Prescription
|
|
from app.db.identity import IdentityBase
|
|
|
|
DB_DIR = Path(__file__).resolve().parent.parent / "app" / "db"
|
|
T0 = datetime(2026, 10, 3, 9, 0, tzinfo=UTC)
|
|
TOKEN = "a" * 32
|
|
TOKEN2 = "b" * 32
|
|
|
|
|
|
@pytest.fixture
|
|
async def dbs() -> AsyncIterator[None]:
|
|
await session.configure(
|
|
identity_url=os.environ.get("TEST_PG_IDENTITY_URL", "sqlite+aiosqlite:///:memory:"),
|
|
clinical_url=os.environ.get("TEST_PG_CLINICAL_URL", "sqlite+aiosqlite:///:memory:"),
|
|
)
|
|
if "TEST_PG_IDENTITY_URL" in os.environ:
|
|
async with session.identity_engine().begin() as conn:
|
|
await conn.run_sync(IdentityBase.metadata.drop_all)
|
|
async with session.clinical_engine().begin() as conn:
|
|
await conn.run_sync(ClinicalBase.metadata.drop_all)
|
|
await init_db()
|
|
yield
|
|
await session.configure()
|
|
|
|
|
|
async def _tables(engine_fn: Callable[[], AsyncEngine]) -> set[str]:
|
|
async with engine_fn().connect() as conn:
|
|
return set(await conn.run_sync(lambda c: inspect(c).get_table_names()))
|
|
|
|
|
|
async def test_create_all_puts_tables_in_the_right_db(dbs: None) -> None:
|
|
identity_tables = await _tables(session.identity_engine)
|
|
clinical_tables = await _tables(session.clinical_engine)
|
|
assert identity_tables == {"patients", "doctors", "connect_codes"}
|
|
assert {
|
|
"patient_profiles",
|
|
"item_responses",
|
|
"scale_results",
|
|
"daily_checkins",
|
|
"prescriptions",
|
|
"doses",
|
|
"alerts",
|
|
"messages",
|
|
"dialog_state",
|
|
"safety_plans",
|
|
"clock_state",
|
|
"audit_log",
|
|
"questionnaire_records",
|
|
"questionnaire_notices",
|
|
"questionnaire_tasks",
|
|
"scheduler_events",
|
|
} == clinical_tables
|
|
await init_db() # idempotent
|
|
|
|
|
|
async def test_identity_roundtrip_and_connect_code(dbs: None) -> None:
|
|
async with session.get_identity_session() as s:
|
|
await identity_repo.upsert_doctor(s, doctor_id="d1", full_name="Доктор Тест", clinic_id="c1", on_call=True)
|
|
await identity_repo.create_patient(
|
|
s,
|
|
token=TOKEN,
|
|
display_name="Пациент",
|
|
snils="000-000-000 00",
|
|
clinic_id="c1",
|
|
doctor_id="d1",
|
|
now=T0,
|
|
)
|
|
await identity_repo.create_connect_code(s, code="ABC123", token=TOKEN, doctor_id="d1", now=T0)
|
|
async with session.get_identity_session() as s:
|
|
assert await identity_repo.redeem_connect_code(s, code="ABC123", now=T0) == TOKEN
|
|
assert await identity_repo.redeem_connect_code(s, code="ABC123", now=T0) is None
|
|
assert await identity_repo.redeem_connect_code(s, code="NOPE", now=T0) is None
|
|
await identity_repo.bind_chat(s, token=TOKEN, tg_chat_id=42)
|
|
patient = await identity_repo.record_consent(s, token=TOKEN, version="v1", now=T0)
|
|
assert patient is not None and identity_repo.has_active_consent(patient)
|
|
async with session.get_identity_session() as s:
|
|
patient = await identity_repo.get_patient_by_chat(s, 42)
|
|
assert patient is not None and patient.token == TOKEN
|
|
assert patient.created_at == T0 and patient.created_at.tzinfo is not None
|
|
await identity_repo.revoke_consent(s, token=TOKEN, now=T0 + timedelta(days=1))
|
|
assert not identity_repo.has_active_consent(patient)
|
|
assert [d.doctor_id for d in await identity_repo.doctors_on_call(s)] == ["d1"]
|
|
|
|
|
|
async def test_snils_and_chat_unique(dbs: None) -> None:
|
|
async with session.get_identity_session() as s:
|
|
await identity_repo.create_patient(
|
|
s, token=TOKEN, display_name="A", snils="111", clinic_id="c", doctor_id="d", now=T0
|
|
)
|
|
with pytest.raises(IntegrityError):
|
|
async with session.get_identity_session() as s:
|
|
await identity_repo.create_patient(
|
|
s, token=TOKEN2, display_name="B", snils="111", clinic_id="c", doctor_id="d", now=T0
|
|
)
|
|
async with session.get_identity_session() as s:
|
|
await identity_repo.create_patient(
|
|
s, token=TOKEN2, display_name="B", snils="222", clinic_id="c", doctor_id="d", now=T0
|
|
)
|
|
await identity_repo.bind_chat(s, token=TOKEN, tg_chat_id=7)
|
|
with pytest.raises(IntegrityError):
|
|
async with session.get_identity_session() as s:
|
|
await identity_repo.bind_chat(s, token=TOKEN2, tg_chat_id=7)
|
|
|
|
|
|
async def test_checkin_upsert_merges_and_is_unique_per_day(dbs: None) -> None:
|
|
day = date(2026, 10, 3)
|
|
async with session.get_clinical_session() as s:
|
|
await repo.upsert_checkin(s, token=TOKEN, day=day, now=T0, mood=4)
|
|
await repo.upsert_checkin(s, token=TOKEN, day=day, now=T0, med_taken=True, side_effects=["тошнота"])
|
|
await repo.upsert_checkin(s, token=TOKEN, day=day + timedelta(days=1), now=T0, mood=3)
|
|
async with session.get_clinical_session() as s:
|
|
rows = await repo.list_checkins(s, TOKEN)
|
|
assert len(rows) == 2
|
|
assert (rows[0].mood, rows[0].med_taken, rows[0].side_effects) == (4, True, ["тошнота"])
|
|
assert [r.day for r in await repo.list_checkins(s, TOKEN, since=day + timedelta(days=1))] == [
|
|
day + timedelta(days=1)
|
|
]
|
|
with pytest.raises(ValueError):
|
|
await repo.upsert_checkin(s, token=TOKEN, day=day, now=T0, bogus=1)
|
|
with pytest.raises(IntegrityError):
|
|
async with session.get_clinical_session() as s:
|
|
s.add(DailyCheckin(token=TOKEN, day=day, side_effects=[], updated_at=T0))
|
|
|
|
|
|
async def test_scales_profiles_prescriptions(dbs: None) -> None:
|
|
async with session.get_clinical_session() as s:
|
|
await repo.upsert_profile(s, token=TOKEN, doctor_id="d1", modules=["veteran", "depression"])
|
|
await repo.upsert_profile(s, token=TOKEN2, doctor_id="d2")
|
|
await repo.upsert_profile(s, token=TOKEN, next_visit=date(2026, 10, 20))
|
|
await repo.add_item_response(s, token=TOKEN, scale="phq9", item_id="9", score=1, source="button", ts=T0)
|
|
await repo.add_scale_result(
|
|
s,
|
|
token=TOKEN,
|
|
scale="phq9",
|
|
total=16,
|
|
severity="moderately_severe",
|
|
complete=True,
|
|
ts=T0,
|
|
item9=1,
|
|
scheduled=False,
|
|
flags={"trigger": "low_mood_3d"},
|
|
)
|
|
rx = Prescription(token=TOKEN, drug="sertraline", dose="50 mg", schedule={"times": ["08:00"]})
|
|
s.add(rx)
|
|
await s.flush()
|
|
s.add(Dose(token=TOKEN, prescription_id=rx.id, planned_at=T0))
|
|
async with session.get_clinical_session() as s:
|
|
profiles = await repo.patients_of_doctor(s, "d1")
|
|
assert [(p.token, p.modules, p.next_visit) for p in profiles] == [
|
|
(TOKEN, ["veteran", "depression"], date(2026, 10, 20))
|
|
]
|
|
(result,) = await repo.list_scale_results(s, TOKEN, "phq9")
|
|
assert (result.item9, result.scheduled, result.flags) == (1, False, {"trigger": "low_mood_3d"})
|
|
dose = await s.scalar(select(Dose))
|
|
assert dose is not None and dose.status == "pending" and dose.planned_at == T0
|
|
|
|
|
|
async def test_ack_alert_writes_audit(dbs: None) -> None:
|
|
async with session.get_clinical_session() as s:
|
|
alert = await repo.add_alert(
|
|
s,
|
|
token=TOKEN,
|
|
level=AlertLevel.ACUTE,
|
|
code="phq9_item9",
|
|
reason="п.9 ≥ 1",
|
|
ts=T0,
|
|
params={"item9": 1},
|
|
)
|
|
await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="silence", reason="молчание", ts=T0)
|
|
alert_id = alert.id
|
|
assert await repo.open_alert_codes(s, TOKEN) == {"phq9_item9", "silence"}
|
|
later = T0 + timedelta(minutes=5)
|
|
async with session.get_clinical_session() as s:
|
|
acked = await repo.ack_alert(s, alert_id=alert_id, doctor_id="d1", ts=later)
|
|
assert acked is not None and acked.acked_by == "d1"
|
|
await repo.ack_alert(s, alert_id=alert_id, doctor_id="d2", ts=later) # no-op
|
|
assert await repo.ack_alert(s, alert_id=9999, doctor_id="d1", ts=later) is None
|
|
async with session.get_clinical_session() as s:
|
|
assert await repo.open_alert_codes(s, TOKEN) == {"silence"}
|
|
(stored,) = [a for a in await repo.list_alerts(s, token=TOKEN) if a.id == alert_id]
|
|
assert (stored.level, stored.acked_by, stored.acked_at) == ("acute", "d1", later)
|
|
logs = (await s.scalars(select(AuditLog))).all()
|
|
assert [(a.actor, a.action, a.token, a.ts) for a in logs] == [("d1", "ack_alert", TOKEN, later)]
|
|
assert logs[0].details == {"alert_id": alert_id, "code": "phq9_item9", "level": "acute"}
|
|
|
|
|
|
async def test_audit_helper(dbs: None) -> None:
|
|
async with session.get_clinical_session() as s:
|
|
await audit(s, actor="system", action="crisis_acute", token=TOKEN, details=None, ts=T0)
|
|
async with session.get_clinical_session() as s:
|
|
entry = await s.scalar(select(AuditLog))
|
|
assert entry is not None and entry.action == "crisis_acute"
|
|
|
|
|
|
async def test_dialog_state_messages_clock(dbs: None) -> None:
|
|
async with session.get_clinical_session() as s:
|
|
assert await repo.get_dialog_state(s, TOKEN) is None
|
|
await repo.set_dialog_state(s, token=TOKEN, state="checkin_mood", data={"step": 1}, now=T0)
|
|
await repo.add_message(s, token=TOKEN, direction="in", text=ScrubbedText("ок"), ts=T0, fsm_state="idle")
|
|
with pytest.raises(ValueError):
|
|
await repo.add_message(s, token=TOKEN, direction="x", text=ScrubbedText("ок"), ts=T0)
|
|
assert await repo.load_clock_offset(s) == 0.0
|
|
await repo.save_clock_offset(s, 86400.0)
|
|
async with session.get_clinical_session() as s:
|
|
await repo.set_dialog_state(s, token=TOKEN, state="checkin_med", data={"step": 2}, now=T0)
|
|
await repo.save_clock_offset(s, 2 * 86400.0)
|
|
async with session.get_clinical_session() as s:
|
|
state = await repo.get_dialog_state(s, TOKEN)
|
|
assert state is not None and (state.state, state.data) == ("checkin_med", {"step": 2})
|
|
assert await repo.load_clock_offset(s) == 2 * 86400.0
|
|
|
|
|
|
async def test_naive_datetime_rejected(dbs: None) -> None:
|
|
with pytest.raises(Exception, match="naive datetime"):
|
|
async with session.get_clinical_session() as s:
|
|
await repo.add_alert(s, token=TOKEN, level=AlertLevel.INFO, code="x", reason="x", ts=datetime(2026, 1, 1))
|
|
|
|
|
|
async def test_session_rolls_back_on_error(dbs: None) -> None:
|
|
with pytest.raises(RuntimeError):
|
|
async with session.get_clinical_session() as s:
|
|
await repo.upsert_profile(s, token=TOKEN, doctor_id="d1")
|
|
raise RuntimeError
|
|
async with session.get_clinical_session() as s:
|
|
assert await repo.get_profile(s, TOKEN) is None
|
|
|
|
|
|
# --- guardrail #5: clinical code never touches identity ----------------------------------------------
|
|
|
|
CLINICAL_MODULES = ["clinical.py", "repo.py", "audit.py", "session.py", "types.py"]
|
|
|
|
|
|
def _imported_modules(path: Path) -> set[str]:
|
|
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
names: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
names.update(alias.name for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom):
|
|
names.add(node.module or "")
|
|
names.update(f"{node.module}.{alias.name}" for alias in node.names)
|
|
return names
|
|
|
|
|
|
@pytest.mark.parametrize("filename", CLINICAL_MODULES)
|
|
def test_clinical_code_does_not_import_identity(filename: str) -> None:
|
|
imported = _imported_modules(DB_DIR / filename)
|
|
assert not [m for m in imported if "identity" in m], f"{filename} imports identity: {imported}"
|
|
|
|
|
|
def test_clinical_import_graph_excludes_identity() -> None:
|
|
code = "import sys, app.db.repo, app.db.audit; print('app.db.identity' in sys.modules)"
|
|
out = subprocess.run(
|
|
[sys.executable, "-c", code], capture_output=True, text=True, check=True, cwd=DB_DIR.parents[1]
|
|
)
|
|
assert out.stdout.strip() == "False"
|
|
|
|
|
|
def test_metadata_is_separate() -> None:
|
|
assert not set(IdentityBase.metadata.tables) & set(ClinicalBase.metadata.tables)
|
|
assert "audit_log" in ClinicalBase.metadata.tables
|