feat: complete production hardening implementation, specialty canonicalization, google-genai migration, voice reliability, sqlite concurrency, and test suite consolidation

This commit is contained in:
2026-09-07 12:57:54 +05:30
parent c23d979338
commit 92fa71ae4a
99 changed files with 5167 additions and 3550 deletions
@@ -209,7 +209,7 @@ def test_11_emergency_override_tamil():
res = await orchestrator.process(mock_session, state_mock, [], "மூச்சு விட முடியல")
assert "108" in res["reply"]
assert ("மருத்துவமனை" in res["reply"] or "அவசர" in res["reply"])
assert res.get("completed") is True
asyncio.run(run())
@@ -0,0 +1,52 @@
"""
Consolidated Automated Tests from root-level scripts.
Covers:
- Direct symptom classification for all supported specialties
- Auto-specialty triage rules and canonical output checks
- Offline AI provider interface and configuration contracts
"""
import pytest
from app.ai.orchestrator import classify_specialty_from_symptoms
from app.specialties.registry import get_specialty_config, SPECIALTIES, CANONICAL_ALIASES
def test_direct_symptom_classifier_all_specialties():
"""Consolidated test derived from test_direct_classifier.py & test_auto_specialty.py."""
test_cases = [
("Cardiology", "Chest pain and shortness of breath", "cardiology"),
("Dermatology", "Skin rash and severe itching", "dermatology"),
("Orthopedics", "Knee joint pain and swelling", "orthopedics"),
("Neurology", "Headache and dizziness for 3 days", "neurology"),
("Gastroenterology", "Stomach pain and vomiting", "gastroenterology"),
("Pulmonology", "Cough and phlegm for 4 days", "pulmonology"),
("Diabetology", "High blood sugar and insulin history", "diabetology"),
("Mental Health", "Depression and panic attacks", "mental_health"),
("General", "Feverish and weakness", "general"),
]
for label, text, expected_code in test_cases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == expected_code, f"Failed for {label}: expected '{expected_code}', got '{res['code']}'"
def test_registry_canonical_aliases():
"""Verify alias mapping for legacy codes."""
assert get_specialty_config("general_medicine")["code"] == "general"
assert get_specialty_config("psychiatry")["code"] == "mental_health"
def test_all_canonical_specialties_registered():
"""Ensure all required canonical specialties have active, valid configs."""
required = {
"general",
"mental_health",
"diabetology",
"cardiology",
"dermatology",
"neurology",
"orthopedics",
"gastroenterology",
"pulmonology",
}
registered = set(SPECIALTIES.keys())
assert required.issubset(registered), f"Missing registered specialties: {required - registered}"
@@ -534,7 +534,7 @@ def test_acceptance_test_4_nothing_else_completion(mock_session):
# =====================================================================
def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
"""Emergency statement triggers 108 ambulance advice and stops intake."""
"""Emergency statement triggers hospital emergency room/staff advice and stops intake."""
import asyncio
orchestrator = AIOrchestrator()
state_mock = MagicMock()
@@ -547,7 +547,7 @@ def test_emergency_red_flag_crushing_chest_pain(mock_session, empty_state):
user_message="I have crushing chest pain and I cannot breathe!"
))
assert "108" in res["reply"]
assert "emergency" in res["reply"].lower() or "hospital" in res["reply"].lower()
assert res.get("completed") is True
@@ -575,7 +575,7 @@ def test_mental_health_crisis_override(mock_session, empty_state):
def test_specialty_auto_classification():
"""Verify clinical keywords correctly triage to respective specialties."""
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] == "psychiatry"
assert classify_specialty_from_symptoms("I have severe depression and insomnia")["code"] in ("mental_health", "psychiatry")
assert classify_specialty_from_symptoms("Severe chest pain and breathless")["code"] == "cardiology"
assert classify_specialty_from_symptoms("Skin rash and severe itching")["code"] == "dermatology"
assert classify_specialty_from_symptoms("Knee joint pain and swelling")["code"] == "orthopedics"
@@ -975,3 +975,32 @@ def test_specialty_not_downgraded_on_duration_message(mock_session):
# Specialty should remain neurology, not flip to general_medicine
suggested = res.get("suggested_specialty", {})
assert suggested.get("code") == "neurology"
def test_dynamic_symptom_tailored_questions():
"""Verify that follow-up questions dynamically reference the reported symptom name."""
from app.ai.orchestrator import _pick_best_question
# Tamil with headache reported
state_ta = {"reason_for_visit": "தலைவலி", "symptoms": [{"name": "தலைவலி"}]}
q_ta = _pick_best_question(["duration"], "ta-IN", False, current_state=state_ta)
assert "தலைவலி" in q_ta
# English with stomach pain reported
state_en = {"reason_for_visit": "stomach pain", "symptoms": [{"name": "stomach pain"}]}
q_en = _pick_best_question(["duration"], "en-US", False, current_state=state_en)
assert "stomach pain" in q_en
def test_combined_meds_and_allergies_question_pattern():
"""Verify that when medications and allergies are missing, questions are combined naturally."""
from app.ai.orchestrator import _pick_best_question
state_en = {"reason_for_visit": "fever", "symptoms": [{"name": "fever", "duration": "2 days"}], "medical_history": ["Hypertension"]}
q_comb = _pick_best_question(["medications", "allergies"], "en-US", False, current_state=state_en)
# Should ask about both medications AND allergies in one fluid question
assert "medication" in q_comb.lower() or "regula" in q_comb.lower()
assert "allerg" in q_comb.lower()
@@ -0,0 +1,294 @@
import uuid
import asyncio
import concurrent.futures
import pytest
from unittest.mock import AsyncMock, patch, MagicMock
from app.ai.gemini_client import GeminiClient
from app.ai.sarvam_client import (
SarvamClient,
STT_TIMEOUT,
STT_NETWORK_ERROR,
STT_SUCCESS,
TTS_TIMEOUT,
TTS_NETWORK_ERROR,
TTS_SUCCESS,
)
from app.database.database import SessionLocal, engine, Base
from app.database.models import OnboardingSession, OnboardingState, OnboardingMessage
# =====================================================================
# GEMINI CLIENT TESTS
# =====================================================================
@pytest.mark.anyio
async def test_gemini_client():
client = GeminiClient()
assert client.model is not None
# Unconfigured key fallback
with patch.object(client, "api_key", ""):
doc_res = await client.extract_from_document(b"fake", "application/pdf", {})
assert doc_res["document_type"] == "medical_report"
@pytest.mark.anyio
async def test_gemini_timeout():
client = GeminiClient()
client.api_key = "valid_fake_key"
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError):
with pytest.raises(Exception):
await client.generate_response("System prompt", "User msg")
@pytest.mark.anyio
async def test_gemini_provider_error():
client = GeminiClient()
client.api_key = "valid_fake_key"
with patch("asyncio.wait_for", side_effect=RuntimeError("API quota exceeded")):
with pytest.raises(Exception):
await client.generate_response("System prompt", "User msg")
@pytest.mark.anyio
async def test_gemini_malformed_response():
client = GeminiClient()
client.api_key = "valid_fake_key"
mock_resp = MagicMock()
mock_resp.text = "NOT JSON VALUE"
with patch("asyncio.wait_for", return_value=mock_resp):
res = await client.extract_from_document(b"data", "image/png", {})
assert res["document_type"] == "unknown"
# =====================================================================
# VOICE STT / TTS RELIABILITY TESTS
# =====================================================================
@pytest.mark.anyio
async def test_stt_timeout():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = asyncio.TimeoutError
mock_get_client.return_value = mock_client
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
assert transcript == ""
assert status == STT_TIMEOUT
@pytest.mark.anyio
async def test_stt_network_failure():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = Exception("Network unreachable")
mock_get_client.return_value = mock_client
transcript, status = await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=1)
assert transcript == ""
assert status == STT_NETWORK_ERROR
@pytest.mark.anyio
async def test_stt_retry_limit():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
attempt_count = 0
async def mock_post(*args, **kwargs):
nonlocal attempt_count
attempt_count += 1
raise asyncio.TimeoutError
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post = mock_post
mock_get_client.return_value = mock_client
await sarvam.speech_to_text_with_status(b"audio_bytes", max_retries=2)
assert attempt_count == 3 # Initial + 2 retries
@pytest.mark.anyio
async def test_tts_timeout():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = asyncio.TimeoutError
mock_get_client.return_value = mock_client
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
assert audio is None
assert status == TTS_TIMEOUT
@pytest.mark.anyio
async def test_tts_network_failure():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post.side_effect = Exception("Network unreachable")
mock_get_client.return_value = mock_client
audio, status = await sarvam.text_to_speech_with_status("Hello", max_retries=1)
assert audio is None
assert status == TTS_NETWORK_ERROR
@pytest.mark.anyio
async def test_tts_retry_limit():
sarvam = SarvamClient()
sarvam.api_key = "fake_key"
attempt_count = 0
async def mock_post(*args, **kwargs):
nonlocal attempt_count
attempt_count += 1
raise asyncio.TimeoutError
with patch.object(sarvam, "_get_client") as mock_get_client:
mock_client = AsyncMock()
mock_client.post = mock_post
mock_get_client.return_value = mock_client
await sarvam.text_to_speech_with_status("Hello", max_retries=2)
assert attempt_count == 3
@pytest.mark.anyio
async def test_voice_failure_does_not_crash_websocket():
sarvam = SarvamClient()
with patch.object(sarvam, "speech_to_text_with_status", return_value=("", STT_TIMEOUT)):
transcript, status = await sarvam.speech_to_text_with_status(b"audio", max_retries=0)
assert status == STT_TIMEOUT
assert transcript == ""
# =====================================================================
# SQLITE CONCURRENCY TESTS
# =====================================================================
def _worker_write_session(session_idx: int):
db = SessionLocal()
try:
session_id = f"CONC-SESS-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=session_id,
patient_id=f"PAT-{session_idx}",
language="en-IN",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.commit()
msg = OnboardingMessage(
session_id=session_id,
role="patient",
message="Severe fever for 2 days",
stage="chief_complaint",
)
db.add(msg)
db.commit()
return True
except Exception as e:
db.rollback()
raise e
finally:
db.close()
def test_concurrent_session_writes():
"""Simulate 15 concurrent session writes to SQLite under WAL mode."""
Base.metadata.create_all(bind=engine)
with concurrent.futures.ThreadPoolExecutor(max_workers=10) as executor:
futures = [executor.submit(_worker_write_session, i) for i in range(15)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
assert all(results)
def test_concurrent_websocket_sessions():
"""Simulate multiple concurrent session updates without database locks."""
Base.metadata.create_all(bind=engine)
with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
futures = [executor.submit(_worker_write_session, i + 100) for i in range(5)]
results = [f.result() for f in concurrent.futures.as_completed(futures)]
assert len(results) == 5
def test_database_lock_recovery():
db1 = SessionLocal()
db2 = SessionLocal()
try:
s_id = f"LOCK-TEST-{uuid.uuid4()}"
sess1 = OnboardingSession(
session_id=s_id,
patient_id="PAT-L1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db1.add(sess1)
db1.commit()
res1 = db1.query(OnboardingSession).filter_by(session_id=s_id).first()
res2 = db2.query(OnboardingSession).filter_by(session_id=s_id).first()
assert res1 is not None
assert res2 is not None
finally:
db1.close()
db2.close()
def test_transaction_rollback():
db = SessionLocal()
try:
s_id = f"ROLLBACK-TEST-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=s_id,
patient_id="PAT-R1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.flush()
db.rollback()
check = db.query(OnboardingSession).filter_by(session_id=s_id).first()
assert check is None
finally:
db.close()
def test_disconnect_during_db_operation():
db = SessionLocal()
try:
s_id = f"DISCONNECT-TEST-{uuid.uuid4()}"
sess = OnboardingSession(
session_id=s_id,
patient_id="PAT-D1",
language="en",
category_type="general",
category_code="general",
category_name="General",
stage="chief_complaint",
status="in_progress",
)
db.add(sess)
db.commit()
db.close()
db_new = SessionLocal()
check = db_new.query(OnboardingSession).filter_by(session_id=s_id).first()
assert check is not None
db_new.close()
except Exception:
db.close()
raise
@@ -0,0 +1,236 @@
"""
Comprehensive production upgrade test suite for AI Patient Onboarding Orchestrator.
Covers Scenarios 1-7, Emergency precedence, Crisis routing, Family history scope,
Negation, Multi-fact extraction, Rule 51 & 52 response formatting, and State merging.
"""
import os
import sys
import json
import pytest
from unittest.mock import MagicMock, AsyncMock
# Add project root to sys.path
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.ai.orchestrator import (
AIOrchestrator,
merge_state_updates,
classify_specialty_from_symptoms,
check_required_missing,
_field_status,
)
from app.ai.safety.emergency import check_emergency
from app.ai.safety.crisis import check_crisis
from app.ai.extraction.multilingual_normalizer import normalize_text, detect_language
from app.ai.extraction.history_extractor import extract_history
from app.ai.conversation.response_formatter import format_conversational_response
@pytest.fixture
def mock_session():
session = MagicMock()
session.language = "en"
session.category_code = "general_medicine"
session.category_name = "General Medicine"
session.patient_name = "John Doe"
return session
@pytest.fixture
def mock_state():
state = MagicMock()
state.state_json = json.dumps({
"reason_for_visit": None,
"symptoms": [],
"medical_history": [],
"medications": [],
"allergies": []
})
return state
# =====================================================================
# REQUIRED SCENARIOS 1 - 7
# =====================================================================
def test_scenario_1_single_symptom_duration(mock_session, mock_state):
"""
Scenario 1:
Patient: 'I have headache for 3 days.'
Expected: symptom = headache, duration = 3 days. Ask next missing question.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have headache for 3 days."
))
updates = res.get("state_updates", {})
symptoms = updates.get("symptoms", [])
assert len(symptoms) > 0
assert symptoms[0]["name"] == "headache"
assert symptoms[0]["duration"] == "3 days"
assert res.get("completed") is False
def test_scenario_2_multi_fact_extraction(mock_session, mock_state):
"""
Scenario 2:
Patient: 'I have headache for 3 days and it is severe. I also have diabetes and take metformin.'
Expected: headache, duration, severity, diabetes, metformin extracted in one pass.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have headache for 3 days and it is severe. I also have diabetes and take metformin."
))
updates = res.get("state_updates", {})
assert "symptoms" in updates
assert "medical_history" in updates
assert "medications" in updates
# Symptoms
symptoms = updates["symptoms"]
assert symptoms[0]["name"] == "headache"
assert symptoms[0]["duration"] == "3 days"
assert symptoms[0]["severity"] == "severe"
# Medical History
mh = updates["medical_history"]
assert any(h["condition"] == "Diabetes" for h in mh)
# Medications
meds = updates["medications"]
assert any("Metformin" in m["name"] for m in meds)
def test_scenario_3_negation_confirmed_none(mock_session, mock_state):
"""
Scenario 3:
Patient: 'I don't take any medicines and I have no allergies.'
Expected: medications = confirmed_none, allergies = confirmed_none.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I don't take any medicines and I have no allergies."
))
updates = res.get("state_updates", {})
meds = updates.get("medications")
algs = updates.get("allergies")
assert isinstance(meds, dict) and meds.get("status") == "confirmed_none"
assert isinstance(algs, dict) and algs.get("status") == "confirmed_none"
def test_scenario_4_family_history_scope(mock_session, mock_state):
"""
Scenario 4:
Patient: 'My father has diabetes but I don't have diabetes.'
Expected: family_history = father + diabetes, patient medical_history = confirmed_none for diabetes.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="My father has diabetes but I don't have diabetes."
))
updates = res.get("state_updates", {})
fam = updates.get("family_history", [])
assert len(fam) > 0
assert fam[0]["relation"] == "father"
assert fam[0]["condition"] == "Diabetes"
mh = updates.get("medical_history")
# Patient medical_history should NOT have active Diabetes
if isinstance(mh, list):
assert not any(h.get("condition") == "Diabetes" for h in mh)
def test_scenario_5_emergency_precedence(mock_session, mock_state):
"""
Scenario 5:
Patient: 'I have chest pain and difficulty breathing.'
Expected: emergency routing immediately without normal onboarding questioning.
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I have chest pain and difficulty breathing."
))
assert res.get("next_stage") == "emergency_referral"
assert "urgent medical attention" in res.get("reply", "") or "ER" in res.get("reply", "")
def test_scenario_6_crisis_precedence(mock_session, mock_state):
"""
Scenario 6:
Patient: 'I feel very depressed and I don't want to live anymore.'
Expected: crisis routing with Tele-MANAS (14416).
"""
import asyncio
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="I feel very depressed and I don't want to live anymore."
))
assert res.get("next_stage") == "crisis_handling"
assert "14416" in res.get("reply", "") or "Tele-MANAS" in res.get("reply", "")
def test_scenario_7_multilingual_tamil(mock_session, mock_state):
"""
Scenario 7:
Patient: 'எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு.'
Expected: symptom = dizziness, duration = 3 days, severity = severe.
"""
import asyncio
mock_session.language = "ta-IN"
orchestrator = AIOrchestrator()
res = asyncio.run(orchestrator.process(
session=mock_session,
state=mock_state,
conversation_history=[],
user_message="எனக்கு தலை சுத்துது, மூன்று நாளா இருக்கு, ரொம்ப அதிகமா இருக்கு."
))
updates = res.get("state_updates", {})
symptoms = updates.get("symptoms", [])
assert len(symptoms) > 0
assert symptoms[0]["duration"] is not None
assert symptoms[0]["severity"] is not None
# =====================================================================
# RULE 51 & 52 RESPONSE FORMATTING TESTS
# =====================================================================
def test_rule_51_response_length_formatting():
"""Verify format_conversational_response enforces max 1-2 short sentences under 20-25 words."""
long_reply = "Thank you for sharing that information. I understand that you have been experiencing severe headaches for three days. Could you please tell me if you have any other chronic medical conditions like diabetes or high blood pressure?"
formatted = format_conversational_response(long_reply, "en")
words = formatted.split()
assert len(words) <= 25
assert formatted.count(".") + formatted.count("?") + formatted.count("!") <= 2
@@ -0,0 +1,83 @@
import pytest
from app.ai.orchestrator import classify_specialty_from_symptoms, SPECIALTY_RULES
from app.specialties.registry import SPECIALTIES, CANONICAL_ALIASES, get_specialty_config, validate_specialty_registry
def test_diabetology_detection():
"""Verify diabetes keywords resolve to canonical code 'diabetology'."""
phrases = [
"My sugar level is very high",
"I have type 2 diabetes",
"Taking Glycomet 500mg daily",
"HbA1c report shows 8.1%",
"High blood sugar after meals",
"Taking insulin injections",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "diabetology", f"Failed for '{text}': got {res['code']}"
def test_mental_health_canonical_code():
"""Verify mental health symptoms resolve to canonical 'mental_health', not 'psychiatry'."""
phrases = [
"Feeling depressed and anxious",
"Having severe panic attacks and insomnia",
"மன அழுத்தம் அதிகமாக இருக்கு",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "mental_health", f"Failed for '{text}': got {res['code']}"
def test_general_canonical_code():
"""Verify general symptoms resolve to canonical 'general', not 'general_medicine'."""
phrases = [
"I have mild fever and body pain",
"Feeling weak for 2 days",
]
for text in phrases:
res = classify_specialty_from_symptoms(text)
assert res["code"] == "general", f"Failed for '{text}': got {res['code']}"
def test_all_specialties_have_configs():
"""Verify all 9 canonical specialties have dedicated, non-fallback configuration files."""
canonical_codes = [
"general",
"mental_health",
"diabetology",
"cardiology",
"dermatology",
"neurology",
"orthopedics",
"gastroenterology",
"pulmonology",
]
for code in canonical_codes:
assert code in SPECIALTIES, f"Missing specialty config in registry for {code}"
config = get_specialty_config(code)
assert config["code"] == code, f"Specialty {code} resolved to fallback code {config['code']}"
assert len(config.get("focus_areas", [])) > 0
def test_no_supported_specialty_falls_back_to_general():
"""Verify that every classifier output is registered and does not fall back to general."""
validation = validate_specialty_registry()
assert validation["valid"] is True
assert validation["registered_count"] >= 9
assert len(validation["missing_configs"]) == 0
def test_registry_classifier_consistency():
"""Verify all classifier rule codes match registered specialty codes."""
registered = set(SPECIALTIES.keys())
for rule in SPECIALTY_RULES:
code = rule["code"]
assert code in registered, f"Classifier rule code '{code}' is not in registered SPECIALTIES"
def test_alias_compatibility():
"""Verify legacy aliases convert to canonical configs cleanly."""
assert get_specialty_config("general_medicine")["code"] == "general"
assert get_specialty_config("psychiatry")["code"] == "mental_health"
@@ -0,0 +1,250 @@
"""
Comprehensive WebSocket Test Suite (Section 37, 38, 40).
Tests connection lifecycle, handshake, message protocol, typing/processing events,
deduplication, safety alerts, error protocol, multilingual handling, and REST backward compatibility.
"""
import sys
import os
import json
import pytest
from fastapi.testclient import TestClient
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
from app.main import app
client = TestClient(app)
def create_test_session(patient_id: str = "PAT-WS-100", language: str = "en") -> str:
"""Helper to initialize an active onboarding session via REST API."""
response = client.post(
"/v1/onboarding/start",
json={
"patient": {"id": patient_id, "name": "WebSocket Test Patient", "age": 32, "gender": "female"},
"category": {"type": "general", "code": "general_medicine", "name": "General Medicine"},
"language": language
},
headers={"Authorization": f"Bearer test_token_{patient_id}"}
)
assert response.status_code == 200
return response.json()["session_id"]
def test_websocket_successful_connection_ack():
"""Test WS connection lifecycle and connection.ack event (Section 3 & 4)."""
patient_id = "PAT-WS-ACK"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
ack_data = websocket.receive_json()
assert ack_data["type"] == "connection.ack"
assert ack_data["session_id"] == session_id
assert ack_data["status"] == "connected"
def test_websocket_invalid_session_connection():
"""Test connection to non-existent session ID is rejected (Section 24)."""
url = "/api/v1/ws/onboarding/ONB-INVALID-9999?token=test_token_PAT-WS-1"
with client.websocket_connect(url) as websocket:
err = websocket.receive_json()
assert err["type"] == "error"
assert err["code"] == "SESSION_NOT_FOUND"
def test_websocket_unauthorized_session_access():
"""Test connecting to Session A with User B token is rejected (Section 25)."""
session_id = create_test_session(patient_id="PAT-USER-A")
# Connect with token for PAT-USER-B
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_PAT-USER-B"
with client.websocket_connect(url) as websocket:
err = websocket.receive_json()
assert err["type"] == "error"
assert err["code"] == "UNAUTHORIZED"
def test_websocket_end_to_end_message_flow():
"""Required End-to-End Test (Section 38): verify complete event sequence."""
patient_id = "PAT-WS-E2E"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
# Receive ACK
ack = websocket.receive_json()
assert ack["type"] == "connection.ack"
# Send patient.message
websocket.send_json({
"type": "patient.message",
"message_id": "msg_e2e_1",
"text": "I have severe headache for 3 days"
})
events = []
# Expect: processing.started, typing.started, state.updated, assistant.message, typing.stopped, processing.completed
for _ in range(6):
event = websocket.receive_json()
events.append(event)
event_types = [e["type"] for e in events]
assert "processing.started" in event_types
assert "typing.started" in event_types
assert "state.updated" in event_types
assert "assistant.message" in event_types
assert "typing.stopped" in event_types
assert "processing.completed" in event_types
# Verify assistant response properties (Section 5 & 9)
asst_msg = next(e for e in events if e["type"] == "assistant.message")
assert asst_msg["message_id"] == "resp_msg_e2e_1"
assert len(asst_msg["reply"].split()) <= 20
assert asst_msg["reply"].count("?") <= 1
def test_websocket_duplicate_message_prevention():
"""Test duplicate message_id protection returns cached response (Section 17)."""
patient_id = "PAT-WS-DEDUP"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
msg_payload = {
"type": "patient.message",
"message_id": "msg_dedup_101",
"text": "Fever for two days"
}
# First send
websocket.send_json(msg_payload)
events_1 = [websocket.receive_json() for _ in range(6)]
asst_1 = next(e for e in events_1 if e["type"] == "assistant.message")
# Duplicate send with SAME message_id
websocket.send_json(msg_payload)
cached_resp = websocket.receive_json()
assert cached_resp["type"] == "assistant.message"
assert cached_resp["reply"] == asst_1["reply"]
def test_websocket_emergency_safety_alert():
"""Test emergency message triggers safety.alert event (Section 12)."""
patient_id = "PAT-WS-EMERGENCY"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
websocket.send_json({
"type": "patient.message",
"message_id": "msg_emerg_1",
"text": "I have severe crushing chest pain radiating to my arm and shortness of breath"
})
received = []
for _ in range(8): # Includes safety alert and onboarding completed
received.append(websocket.receive_json())
event_types = [e["type"] for e in received]
assert "safety.alert" in event_types
safety_event = next(e for e in received if e["type"] == "safety.alert")
assert safety_event["level"] in ["emergency", "crisis"]
def test_websocket_invalid_json_handling():
"""Test malformed non-JSON text returns error event without closing socket (Section 15)."""
patient_id = "PAT-WS-BADJSON"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
# Send raw non-JSON text
websocket.send_text("THIS_IS_NOT_JSON")
err_resp = websocket.receive_json()
assert err_resp["type"] == "error"
assert err_resp["code"] == "INVALID_MESSAGE"
# Verify socket is still active by sending valid message after
websocket.send_json({
"type": "patient.message",
"message_id": "msg_valid_after_error",
"text": "I am fine now"
})
events = [websocket.receive_json() for _ in range(6)]
assert any(e["type"] == "assistant.message" for e in events)
def test_websocket_oversized_message():
"""Test message > 5000 chars returns MESSAGE_TOO_LARGE error (Section 16)."""
patient_id = "PAT-WS-LARGE"
session_id = create_test_session(patient_id=patient_id)
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
oversized_text = "A" * 5050
websocket.send_json({
"type": "patient.message",
"message_id": "msg_large_1",
"text": oversized_text
})
err_resp = websocket.receive_json()
assert err_resp["type"] == "error"
assert err_resp["code"] == "MESSAGE_TOO_LARGE"
def test_websocket_multilingual_unicode():
"""Test Tamil/Hindi multilingual messages process seamlessly over WebSocket (Section 16)."""
patient_id = "PAT-WS-TAMIL"
session_id = create_test_session(patient_id=patient_id, language="ta")
url = f"/api/v1/ws/onboarding/{session_id}?token=test_token_{patient_id}"
with client.websocket_connect(url) as websocket:
websocket.receive_json() # ACK
websocket.send_json({
"type": "patient.message",
"message_id": "msg_ta_1",
"text": "எனக்கு 3 நாளா தலைவலி இருக்கு"
})
events = [websocket.receive_json() for _ in range(6)]
asst = next(e for e in events if e["type"] == "assistant.message")
assert asst["reply"] is not None
assert len(asst["reply"]) > 0
def test_rest_api_backward_compatibility():
"""Verify Section 36 requirement: REST APIs continue working untouched."""
session_id = create_test_session(patient_id="PAT-REST-BACKWARD")
# Send REST message
res = client.post(
f"/v1/onboarding/{session_id}/message",
json={"message": "Headache for two days"},
headers={"Authorization": "Bearer test_token_PAT-REST-BACKWARD"}
)
assert res.status_code == 200
assert res.json()["success"] is True
assert "message" in res.json()
def test_websocket_tester_ui_endpoint():
"""Verify that the sample UI page for WebSocket testing returns 200 OK HTML."""
res = client.get("/ws-tester")
assert res.status_code == 200
assert "text/html" in res.headers["content-type"]
assert "AI Patient Onboarding" in res.text
assert "WebSocket" in res.text