"""Synthetic released-config integration tests. Production approvals stay empty.""" import asyncio import shutil from datetime import UTC, datetime, timedelta import pytest import yaml from sqlalchemy import select from app.config import get_settings from app.core import clock from app.core.scales import clear_cache from app.db import repo, session from app.db.bootstrap import init_db from app.db.intake import QuestionnaireNotice, QuestionnaireRecord, QuestionnaireTask from app.dialog.contracts import Event from app.dialog.engine import Engine from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations from app.llm.gateway import LLMGateway from app.llm.mock import MockTasks from app.scheduler import jobs from app.services import questionnaire as service TOKEN = "b" * 32 NOW = datetime(2026, 10, 3, 17, tzinfo=UTC) @pytest.fixture async def released(tmp_path, monkeypatch): config = tmp_path / "config" shutil.copytree(get_settings().config_dir, config) for name in ("questionnaire", "intake_runtime"): path = config / f"{name}.yaml" data = yaml.safe_load(path.read_text()) approval = dict( reviewer="Synthetic test", decision_reference="fixture-only", approved_at=NOW.isoformat(), policy_version=data["version"], ) data["approval"] = approval if name == "questionnaire": data.update(cssrs_positive_policy="graded", rus_audit_s_expand_at=1) else: data.update(enabled=True, content_review=approval, contact_review=approval) path.write_text(yaml.safe_dump(data, allow_unicode=True)) monkeypatch.setattr(get_settings(), "config_dir", config) monkeypatch.setattr(clock, "now", lambda: NOW) monkeypatch.setattr(integrations, "notifier", MemoryNotifier()) monkeypatch.setattr(integrations, "outbox", MemoryOutbox()) clear_cache() await session.configure(identity_url="sqlite+aiosqlite:///:memory:", clinical_url="sqlite+aiosqlite:///:memory:") await init_db() async with session.get_clinical_session() as s: await repo.upsert_profile(s, token=TOKEN, doctor_id="synthetic-doctor", modules=["veteran"], sex="m") await repo.set_dialog_state(s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=NOW) yield Engine(gateway=LLMGateway(MockTasks())) await session.configure() clear_cache() async def rows(cls=QuestionnaireRecord): async with session.get_clinical_session() as s: return list((await s.scalars(select(cls))).all()) async def press(e, data): return await e.handle(TOKEN, Event(type="button", button=data)) def answer_button(replies, choices=None): choices = choices or {} for reply in reversed(replies): for line in reply.buttons: for b in line: if b.data.startswith("iq:"): item = b.data.split(":")[2] index = choices.get(item, 1 if item.startswith("cssrs_") else 0) for line2 in reply.buttons: for other in line2: if other.data.endswith(":" + str(index)) and other.data.startswith("iq:"): return other.data return None async def complete_intake(e, choices=None): replies = await press(e, "intake:start") for _ in range(100): button = answer_button(replies, choices) if button is None: return replies replies = await press(e, button) raise AssertionError("intake did not terminate") async def test_minimal_intake_raw_data_not_imputed_and_restart(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) (row,) = await rows() assert row.status == "complete" assert row.answers["trauma"] == 0 and not any(k.startswith("tsq_") for k in row.answers) assert row.details["totals"]["tsq"]["total"] is None assert row.details["totals"]["phq9"]["total"] is None assert row.details["totals"]["rus_audit_s"]["total"] == 0 assert row.details["totals"]["cssrs_recent"]["risk"] == "none" assert row.details["decision"]["primary_route"] == "self_help" async def test_pause_restart_and_duplicate_callbacks(released): replies = await press(released, "intake:start") old = answer_button(replies) await asyncio.gather(press(released, old), press(released, old)) (row,) = await rows() assert row.answers == {"cssrs_q1": 0} await press(released, "intake:pause") restarted = Engine(gateway=LLMGateway(MockTasks())) replies = await press(restarted, "intake:resume") assert "cssrs_q2" in answer_button(replies) await press(restarted, old) assert (await rows())[0].answers == {"cssrs_q1": 0} assert all(len(b.data.encode()) <= 64 for r in replies for line in r.buttons for b in line) @pytest.mark.parametrize("signal", ["help", "phq", "cssrs"]) async def test_immediate_stop_preserves_partial_answers(released, signal): replies = await press(released, "intake:start") if signal == "help": replies = await press(released, "intake:help") elif signal == "cssrs": for item in ("cssrs_q1", "cssrs_q2", "cssrs_q3", "cssrs_q4"): replies = await press(released, answer_button(replies, {item: 0})) else: replies = await complete_existing(released, replies, {"trauma": 1, "phq9_1": 2, "phq9_9": 1}) assert any(r.kind == "crisis" for r in replies) (row,) = await rows() assert row.status == "interrupted" and row.completed_at is None notices = await rows(QuestionnaireNotice) assert len(notices) == 1 and notices[0].status == "test_sink" assert "уведомлён" not in replies[0].text async def complete_existing(e, replies, choices): for _ in range(100): button = answer_button(replies, choices) if not button: return replies replies = await press(e, button) raise AssertionError("not terminated") async def test_full_tsq_expands_at_six_and_carries_alcohol_answers(released): choices = {"tsq_" + str(i): 0 if i <= 6 else 1 for i in range(1, 11)} choices.update(rus_audit_3=1, rus_audit_1=1, tbi=1, psychotropics=1) await complete_intake(released, choices) (row,) = await rows() assert row.status == "complete" assert row.details["totals"]["tsq"]["total"] == 6 assert row.details["totals"]["pcl5"]["total"] == 0 assert row.details["totals"]["rus_audit"]["complete"] assert row.answers["rus_audit_3"] == 1 assert len([k for k in row.answers if k.startswith("rus_audit_")]) == 10 async def test_alcohol_conflict_keeps_positive_short_answer(released): await complete_intake(released, {"trauma": 1, "rus_audit_3": 1, "rus_audit_1": 0}) (row,) = await rows() assert row.status == "review_required" and row.answers["rus_audit_3"] == 1 assert row.details["stop_reason"] == "answer_conflict" async def test_low_risk_shows_safety_plan_but_moderate_stops(released): replies = await press(released, "intake:start") replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) replies = await press(released, answer_button(replies)) replies = await press(released, answer_button(replies)) assert any(r.kind == "safety_plan" for r in replies) assert "phq9_1" in answer_button(replies) async def test_notification_failure_retry_and_consent_cancellation(released, monkeypatch): class Failing: async def send(self, *args, **kwargs): raise OSError("sensitive error content must not be stored") monkeypatch.setattr(integrations, "notifier", Failing()) await press(released, "intake:start") await press(released, "intake:help") (notice,) = await rows(QuestionnaireNotice) assert notice.status == "pending" and notice.error_code == "OSError" and notice.attempts == 1 monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(minutes=3)) notifier = MemoryNotifier() monkeypatch.setattr(integrations, "notifier", notifier) await service.dispatch_notices() await service.dispatch_notices() assert len(notifier.sent) == 1 assert (await rows(QuestionnaireNotice))[0].attempts == 2 await press(released, "consent:revoke") async with session.get_clinical_session() as s: state = await repo.get_dialog_state(s, TOKEN) assert state.data["consent"] is False and "questionnaire_record" not in state.data async def test_scheduler_idempotent_and_failed_outbox_recovery(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) class Failing: async def push(self, *args): raise OSError() monkeypatch.setattr(integrations, "outbox", Failing()) await jobs.run_until_now() tasks = await rows(QuestionnaireTask) assert len(tasks) == 2 risk = next(t for t in tasks if t.kind == "cssrs_since_contact") assert risk.delivery and risk.sent_at is None outbox = MemoryOutbox() monkeypatch.setattr(integrations, "outbox", outbox) await jobs.run_until_now() await jobs.run_until_now() assert len(await rows(QuestionnaireTask)) == 2 assert len(outbox.drain(TOKEN)) == 1 assert len(await rows()) == 2 async def test_missing_since_contact_is_manual_review_not_recent(released): async with session.get_clinical_session() as s: task = await service.queue_task(s, TOKEN, "cssrs_since_contact", "synthetic", NOW) task_id = task.id replies = await released.handle( TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) ) (row,) = await rows() assert row.kind == "cssrs_since_contact" and row.status == "review_required" and not row.answers assert row.details["stop_reason"] == "cssrs_since_contact_required" assert not answer_button(replies) async def test_disabled_or_no_adult_confirmation_cannot_start(released): async with session.get_clinical_session() as s: state = await repo.get_dialog_state(s, TOKEN) state.data = {"consent": True} await press(released, "intake:start") assert await rows() == [] async def review_risk_record(e): from app.api.questionnaire import ReviewIn, review risk = next(r for r in reversed(await rows()) if r.kind == "cssrs_since_contact") return await review( TOKEN, risk.id, ReviewIn( doctor_id="synthetic-doctor", assessment_performed=True, decision="resume", reference="synthetic/assessment" ), ) async def test_doctor_review_is_audited_and_unblocks_diary(released): from fastapi import HTTPException from app.api.questionnaire import ReviewIn, history, review from app.db.audit import AuditLog await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await jobs.run_until_now() risk = next(r for r in await rows() if r.kind == "cssrs_since_contact") with pytest.raises(HTTPException) as exc: await review( TOKEN, risk.id, ReviewIn(doctor_id="other-doctor", assessment_performed=True, decision="resume", reference="test"), ) assert exc.value.status_code == 404 await released.handle(TOKEN, Event(type="system", system="doctor_ack")) async with session.get_clinical_session() as s: assert (await repo.get_dialog_state(s, TOKEN)).data.get("intake_review_required") await review_risk_record(released) await jobs.run_until_now() assert any(r.kind == "diary" and r.status == "active" for r in await rows()) await history(TOKEN, "synthetic-doctor") audits = await rows(AuditLog) assert {a.action for a in audits} >= {"view_questionnaires", "review_questionnaire"} async def test_diary_numeric_skip_and_not_prescribed(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) await press(released, "menu:checkin") await review_risk_record(released) replies = await press(released, "menu:checkin") for _ in range(30): q = next( (b.data.split(":")[2] for r in replies for line in r.buttons for b in line if b.data.startswith("iq:")), None, ) if q == "comment": button = next(b.data for r in replies for line in r.buttons for b in line if b.data.endswith(":skip")) replies = await press(released, button) elif q: replies = await press(released, answer_button(replies, {"medication": 3})) elif any("часов" in r.text for r in replies): invalid = await released.handle(TOKEN, Event(type="text", text="nan")) assert any("число часов" in r.text for r in invalid) replies = await released.handle(TOKEN, Event(type="text", text="6,5")) else: break diary = next(r for r in await rows() if r.kind == "diary") assert diary.status == "complete" and diary.answers["sleep_hours"] == 6.5 assert "medication_reason" not in diary.answers and "side_effects" not in diary.answers assert diary.answers["comment"] is None async def test_high_risk_diary_asks_safety_first_and_stops(released): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True await press(released, "menu:checkin") await review_risk_record(released) replies = await press(released, "menu:checkin") button = answer_button(replies) assert "death_thoughts" in button replies = await press(released, button) assert any(r.kind == "crisis" for r in replies) assert any(b.data == "safety_plan" for r in replies for line in r.buttons for b in line) diary = next(r for r in await rows() if r.kind == "diary") assert diary.answers == {"death_thoughts": 1} and diary.status == "interrupted" async def test_unknown_sex_goes_to_review_without_a_zone(released): async with session.get_clinical_session() as s: (await repo.get_profile(s, TOKEN)).sex = None await complete_intake(released, {"trauma": 1, "sex": 2}) (row,) = await rows() assert row.status == "review_required" and row.answers["sex"] == "unknown" async def test_version_change_blocks_resume_and_preserves_answers(released): replies = await press(released, "intake:start") await press(released, answer_button(replies)) await press(released, "intake:pause") path = get_settings().config_dir / "instruments/intake_forms.yaml" data = yaml.safe_load(path.read_text()) data["version"] = "synthetic-next-version" path.write_text(yaml.safe_dump(data, allow_unicode=True)) await press(released, "intake:resume") (row,) = await rows() assert row.status == "review_required" and row.answers == {"cssrs_q1": 0} async def test_followup_delta_uses_comparable_complete_baseline(released): await complete_intake(released, {"trauma": 1, "phq9_1": 2, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: task = await service.queue_task(s, TOKEN, "phq9", "synthetic-repeat", NOW) task_id = task.id replies = await released.handle( TOKEN, Event(type="system", system="daily_checkin", params={"questionnaire_task": task_id}) ) replies = await complete_existing(released, replies, {"phq9_1": 3, "phq9_2": 3, "phq9_3": 1}) repeat = next(r for r in await rows() if r.kind == "phq9") assert repeat.status == "complete" and repeat.details["delta"]["value"] == 5 assert repeat.details["delta"]["retriage"] is True async def test_silence_uses_completed_checkins_not_empty_records(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) async with session.get_clinical_session() as s: (await repo.get_profile(s, TOKEN)).enhanced_monitoring = True monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=2)) await jobs.plan() await jobs.plan() notices = await rows(QuestionnaireNotice) assert len([n for n in notices if n.event_key.startswith("silence:")]) == 1 async def test_stop_any_policy_interrupts_first_positive(released): path = get_settings().config_dir / "questionnaire.yaml" data = yaml.safe_load(path.read_text()) data["cssrs_positive_policy"] = "stop_any" path.write_text(yaml.safe_dump(data, allow_unicode=True)) replies = await press(released, "intake:start") replies = await press(released, answer_button(replies, {"cssrs_q1": 0})) assert any(r.kind == "crisis" for r in replies) assert (await rows())[0].answers == {"cssrs_q1": 1} async def test_evening_schedule_uses_moscow_date(released, monkeypatch): await complete_intake(released, {"trauma": 1, "tbi": 1, "psychotropics": 1}) monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=15, minute=59)) await jobs.plan() assert await rows(QuestionnaireTask) == [] monkeypatch.setattr(clock, "now", lambda: NOW.replace(hour=16, minute=0)) await jobs.plan() assert {t.kind for t in await rows(QuestionnaireTask)} == {"diary", "cssrs_since_contact"} async def test_resumed_answer_keeps_original_period(released, monkeypatch): replies = await press(released, "intake:start") await press(released, answer_button(replies)) await press(released, "intake:pause") monkeypatch.setattr(clock, "now", lambda: NOW + timedelta(days=1)) replies = await press(released, "intake:resume") await press(released, answer_button(replies)) (record,) = await rows() metadata = record.details["answer_metadata"]["cssrs_q2"] assert metadata["period_start"] == "2026-09-03" assert metadata["period_end"] == "2026-10-03" assert metadata["answered_at"].startswith("2026-10-04")