"""API #12: dialog endpoints (events, outbox, history) and demo controls (clock, reset, personas).""" from collections.abc import AsyncIterator from datetime import datetime, timedelta from pathlib import Path import httpx import pytest from app.api import demo from app.config import Settings from app.core import clock from app.core.types import ScrubbedText from app.db import identity_repo, repo, session from app.db.bootstrap import init_db from app.dialog.contracts import Reply from app.integrations.base import MemoryNotifier, MemoryOutbox, integrations from app.main import app MEM = "sqlite+aiosqlite:///:memory:" TOKEN = "a" * 32 DAY0 = datetime(2026, 10, 5, 9, 0) @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) await session.configure(identity_url=MEM, clinical_url=MEM) await init_db() async with session.get_identity_session() as ids: await identity_repo.create_patient( ids, token=TOKEN, display_name="Андрей С.", snils="11122233344", phone="+79990000001", clinic_id="c", doctor_id="doc-smirnova", now=clock.now(), birth_year=1992, ) async with session.get_clinical_session() as s: await repo.upsert_profile( s, token=TOKEN, doctor_id="doc-smirnova", modules=["veteran"], therapy_start=clock.today() - timedelta(days=13), ) await repo.set_dialog_state( s, token=TOKEN, state="idle", data={"consent": True, "adult": True}, now=clock.now() ) 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 test_events_reach_engine_and_unknown_patient_is_404(client: httpx.AsyncClient) -> None: r = await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "Привет"}) assert r.status_code == 200 and r.json()["replies"] r = await client.post("/api/v1/dialog/" + "f" * 32 + "/events", json={"type": "text", "text": "x"}) assert r.status_code == 404 async def test_outbox_drains_once(client: httpx.AsyncClient) -> None: await integrations.outbox.push(TOKEN, [Reply(text="Пора принять лекарство")]) first = (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] assert [x["text"] for x in first] == ["Пора принять лекарство"] assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == [] async def test_outbox_found_behind_wrapping_outbox(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: inner = MemoryOutbox() class Wrapper: # like TelegramOutbox: unbound chats fall back to the console queue fallback = inner async def push(self, token: str, replies: list[Reply]) -> None: await inner.push(token, replies) monkeypatch.setattr(integrations, "outbox", Wrapper()) await inner.push(TOKEN, [Reply(text="x")]) assert len((await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"]) == 1 async def test_history_oldest_first_with_limit(client: httpx.AsyncClient) -> None: async with session.get_clinical_session() as s: for i in range(5): await repo.add_message( s, token=TOKEN, direction="in" if i % 2 == 0 else "out", text=ScrubbedText(f"m{i}"), ts=clock.now() + timedelta(minutes=i), ) rows = (await client.get(f"/api/v1/dialog/{TOKEN}/history", params={"limit": 3})).json() assert [m["text"] for m in rows] == ["m2", "m3", "m4"] assert [m["direction"] for m in rows] == ["in", "out", "in"] async def test_history_stores_scrubbed_text(client: httpx.AsyncClient) -> None: await client.post(f"/api/v1/dialog/{TOKEN}/events", json={"type": "text", "text": "мой телефон +7 999 123-45-67"}) texts = " ".join(m["text"] for m in (await client.get(f"/api/v1/dialog/{TOKEN}/history")).json()) assert "123-45-67" not in texts and "999" not in texts async def test_clock_advance_and_reset(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(demo, "SEED_MODULE", "scripts.no_such_seed_module") before = (await client.get("/api/v1/demo/clock")).json() r = (await client.post("/api/v1/demo/clock/advance", json={"hours": 2})).json() assert r["virtual"] is True and r["offset_days"] == pytest.approx(before["offset_days"] + 2 / 24, abs=1e-3) await integrations.outbox.push(TOKEN, [Reply(text="stale")]) assert (await client.post("/api/v1/demo/reset")).json() == {"status": "ok", "seed": "skipped"} assert (await client.get("/api/v1/demo/clock")).json()["offset_days"] == pytest.approx(0, abs=1e-6) assert (await client.get(f"/api/v1/dialog/{TOKEN}/outbox")).json()["replies"] == [] async def test_reset_calls_seed_module(client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None: import sys import types calls: list[str] = [] fake = types.ModuleType("fake_seed_demo") async def reset() -> None: calls.append("reset") fake.reset = reset # type: ignore[attr-defined] monkeypatch.setitem(sys.modules, "fake_seed_demo", fake) monkeypatch.setattr(demo, "SEED_MODULE", "fake_seed_demo") monkeypatch.setattr(demo.util, "find_spec", lambda name: object()) assert (await client.post("/api/v1/demo/reset")).json()["seed"] == "reset" assert calls == ["reset"] async def test_personas_from_scenario_files( client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: monkeypatch.setattr(demo, "get_settings", lambda: Settings(_env_file=None, data_dir=tmp_path)) assert (await client.get("/api/v1/demo/personas")).json() == [] (tmp_path / "synthetic").mkdir() (tmp_path / "synthetic" / "andrey.jsonl").write_text('{"day": 0, "actions": []}\n', encoding="utf-8") personas = (await client.get("/api/v1/demo/personas")).json() assert personas == [ {"token": TOKEN, "display_name": "Андрей С.", "therapy_day": 13, "scenario": "data/synthetic/andrey.jsonl"} ] def test_persona_slug() -> None: assert demo.persona_slug("Андрей С.") == "andrey" assert demo.persona_slug("Юлия Щ.") == "yuliya" assert demo.persona_slug("") == ""