carepath-api / interpreter /tests /test_api.py
tranth3truong's picture
Deploy public Scribe-only CarePath Space
cc678b9
Raw
History Blame Contribute Delete
14.6 kB
import logging
import anyio
from fastapi.testclient import TestClient
import app.api as api_module
from app import crud
from app.api import websocket_session
from app.config import Settings
from app.main import app
from app.providers.mock import MockASRProvider, MockMTProvider, MockReviewerProvider
from app.providers.registry import ProviderSet
class DisconnectOnInitialState:
accepted = False
async def accept(self) -> None:
self.accepted = True
async def send_json(self, _payload: object) -> None:
raise RuntimeError("client disconnected")
def test_websocket_ignores_disconnect_before_initial_state(db_session) -> None:
session = crud.create_session(db_session, {"ok": True})
websocket = DisconnectOnInitialState()
anyio.run(websocket_session, websocket, session.id, db_session)
assert websocket.accepted is True
def test_session_rest_and_websocket_text_turn(db_session) -> None:
del db_session
with TestClient(app) as client:
session_response = client.post(
"/api/sessions",
json={"consent": {"ai_disclosure": True, "patient": "test"}},
)
assert session_response.status_code == 201
session_id = session_response.json()["session_id"]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
assert ws.receive_json() == {"type": "session_state", "turns": []}
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
result = ws.receive_json()
assert result["type"] == "turn_result"
assert result["requires_confirmation"] is False
assert result["low_confidence"] is False
assert result["turn"]["source_text"] == "xin chao"
assert result["turn"]["translation"] == "[vi->en] xin chao"
transcript = client.get(f"/api/sessions/{session_id}/transcript").json()
assert len(transcript) == 1
turn_id = transcript[0]["id"]
confirm = client.post(f"/api/turns/{turn_id}/confirm", json={})
assert confirm.status_code == 409
feedback = client.post(
f"/api/turns/{turn_id}/feedback",
json={"reason": "other", "comment": "ok"},
)
assert feedback.status_code == 201
def test_websocket_rejects_overlapping_turns(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json({"type": "start_turn", "speaker": "doctor", "lang": "vi"})
ws.send_json({"type": "start_turn", "speaker": "patient", "lang": "en"})
assert ws.receive_json() == {
"type": "turn_error",
"message": "turn already in progress",
"retryable": True,
}
def test_dropped_turn_does_not_block_reconnect(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json({"type": "start_turn", "speaker": "doctor", "lang": "vi"})
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
assert ws.receive_json() == {"type": "session_state", "turns": []}
def test_websocket_asr_failure_sends_error_and_keeps_socket(db_session, monkeypatch) -> None:
del db_session
monkeypatch.setattr(
"app.api.get_providers",
lambda settings: ProviderSet(
asr=MockASRProvider(fail=True),
mt=MockMTProvider(),
reviewer=MockReviewerProvider(),
),
)
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json({"type": "start_turn", "speaker": "doctor", "lang": "vi"})
ws.send_bytes(b"xin chao")
ws.send_json({"type": "end_turn"})
assert ws.receive_json() == {
"type": "turn_error",
"message": "translation failed — retry or use typed fallback",
"retryable": True,
"failure_context": {
"speaker": "doctor",
"src_lang": "vi",
"tgt_lang": "en",
"source_text": None,
"translation": None,
},
}
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
assert ws.receive_json()["type"] == "turn_result"
def test_websocket_mt_failure_sends_error_and_keeps_socket(db_session, monkeypatch) -> None:
del db_session
mt = MockMTProvider(fail=True)
monkeypatch.setattr(
"app.api.get_providers",
lambda settings: ProviderSet(
asr=MockASRProvider(),
mt=mt,
reviewer=MockReviewerProvider(),
),
)
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
assert ws.receive_json() == {
"type": "turn_error",
"message": "translation failed — retry or use typed fallback",
"retryable": True,
"failure_context": {
"speaker": "doctor",
"src_lang": "vi",
"tgt_lang": "en",
"source_text": "xin chao",
"translation": None,
},
}
mt.fail = False
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
assert ws.receive_json()["type"] == "turn_result"
def test_websocket_rejects_new_turns_on_ended_session(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
assert client.post(f"/api/sessions/{session_id}/end").status_code == 204
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
assert ws.receive_json() == {
"type": "turn_error",
"message": "session ended",
"retryable": False,
}
def test_websocket_rejects_oversized_audio(db_session, monkeypatch) -> None:
del db_session
monkeypatch.setattr("app.api.get_settings", lambda: Settings(max_turn_audio_bytes=3))
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json({"type": "start_turn", "speaker": "doctor", "lang": "vi"})
ws.send_bytes(b"abcd")
assert ws.receive_json() == {
"type": "turn_error",
"message": "audio turn too large",
"retryable": True,
}
def test_websocket_rejects_oversized_typed_turn(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "x" * 2001,
}
)
assert ws.receive_json() == {
"type": "turn_error",
"message": "typed turn too long",
"retryable": True,
}
def test_info_logs_do_not_include_turn_text(db_session, caplog) -> None:
del db_session
caplog.set_level(logging.INFO, logger="app.api")
sensitive_text = "patient-secret-phrase"
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": sensitive_text,
}
)
assert ws.receive_json()["type"] == "turn_result"
messages = [record.getMessage() for record in caplog.records if record.name == "app.api"]
assert "turn_processed" in messages
assert sensitive_text not in caplog.text
assert f"[vi->en] {sensitive_text}" not in caplog.text
def test_confirm_rejects_ended_session_and_delivered_turn(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "xin chao",
}
)
delivered_id = ws.receive_json()["turn"]["id"]
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "uống 500 mg",
}
)
awaiting_id = ws.receive_json()["turn"]["id"]
assert client.post(f"/api/turns/{delivered_id}/confirm", json={}).status_code == 409
assert client.post(f"/api/sessions/{session_id}/end").status_code == 204
assert client.post(f"/api/turns/{awaiting_id}/confirm", json={}).status_code == 409
def test_admin_review_filters_flagged_risk_and_exports_csv(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "doctor",
"lang": "vi",
"text": "uống 500 mg",
}
)
turn = ws.receive_json()["turn"]
client.post(
f"/api/turns/{turn['id']}/feedback",
json={"reason": "wrong_term", "comment": "dose needs review"},
)
assert client.get("/api/admin/review").status_code == 401
response = client.get(
"/api/admin/review?risk=high&flagged=1&limit=1",
headers={"X-Admin-Token": "change-me"},
)
assert response.status_code == 200
payload = response.json()
assert payload["total"] == 1
assert payload["items"][0]["id"] == turn["id"]
assert payload["items"][0]["feedback"][0]["reason"] == "wrong_term"
csv_response = client.get(
"/api/admin/review?format=csv&flagged=1",
headers={"X-Admin-Token": "change-me"},
)
assert csv_response.status_code == 200
assert "wrong_term" in csv_response.text
def test_admin_review_uses_constant_time_token_comparison(db_session, monkeypatch) -> None:
del db_session
calls: list[tuple[str, str]] = []
def compare_digest(left: str, right: str) -> bool:
calls.append((left, right))
return left == right
monkeypatch.setattr(api_module.hmac, "compare_digest", compare_digest)
with TestClient(app) as client:
assert client.get("/api/admin/review").status_code == 401
assert client.get(
"/api/admin/review", headers={"X-Admin-Token": "change-me"}
).status_code == 200
assert calls == [("", "change-me"), ("change-me", "change-me")]
def test_admin_review_escalated_filter(db_session) -> None:
del db_session
with TestClient(app) as client:
session_id = client.post("/api/sessions", json={"consent": {"ok": True}}).json()[
"session_id"
]
with client.websocket_connect(f"/ws/sessions/{session_id}") as ws:
ws.receive_json()
ws.send_json(
{
"type": "text_turn",
"speaker": "patient",
"lang": "en",
"text": "I have chest pain",
}
)
turn = ws.receive_json()["turn"]
client.post(f"/api/sessions/{session_id}/escalate")
response = client.get(
"/api/admin/review?escalated=1",
headers={"X-Admin-Token": "change-me"},
)
assert response.status_code == 200
assert response.json()["items"][0]["id"] == turn["id"]