feat(backend): API stage 1 on real data instead of fixtures
CI / ruff · pytest (pull_request) Successful in 1m25s
CI / ruff · pytest (pull_request) Successful in 1m25s
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>
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
"""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("") == ""
|
||||
Reference in New Issue
Block a user