""" Tests pour l'API TIM. Exigences : 10.1, 10.6, 10.7, 10.8, 10.9 """ import json from datetime import datetime import pytest from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from pipeline_mco_pmsi.api.tim_api import app, get_db from pipeline_mco_pmsi.database.base import Base # Import ALL models at module level to ensure they are registered with Base.metadata from pipeline_mco_pmsi.database.models import ( StayDB, ClinicalDocumentDB, ClinicalFactDB, CodeDB, EvidenceDB, ValidationIssueDB, QuestionDB, AuditRecordDB, TIMCorrectionDB, VerificationResultDB, GroupageResultDB, ReferentielVersionDB, ) # Fixture pour la base de données de test @pytest.fixture def test_db(tmp_path): """Crée une base de données de test en mémoire.""" # Use a temporary file instead of :memory: to ensure all connections see the same database db_path = tmp_path / "test.db" # Configure SQLite for testing with check_same_thread=False engine = create_engine( f"sqlite:///{db_path}", connect_args={"check_same_thread": False}, ) # Create all tables - models are already imported at module level Base.metadata.create_all(engine) TestingSessionLocal = sessionmaker(bind=engine) db = TestingSessionLocal() yield db db.close() engine.dispose() @pytest.fixture def client(test_db): """Crée un client de test FastAPI.""" # Get the engine from the test_db session engine = test_db.get_bind() # Create a session factory for this engine TestingSessionLocal = sessionmaker(bind=engine) def override_get_db(): db = TestingSessionLocal() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_get_db with TestClient(app) as test_client: yield test_client app.dependency_overrides.clear() @pytest.fixture def sample_stay(test_db): """Crée un séjour de test avec codes et preuves.""" # Créer le séjour stay = StayDB( stay_id="STAY_TEST_001", admission_date=datetime(2024, 1, 15), discharge_date=datetime(2024, 1, 20), specialty="chirurgie", unit="Chirurgie digestive", age=45, sex="M", ) test_db.add(stay) test_db.flush() # Créer un document document = ClinicalDocumentDB( stay_id=stay.id, document_id="DOC001", document_type="cr_operatoire", content="Patient opéré pour appendicite aiguë. Intervention réalisée sous anesthésie générale.", creation_date=datetime(2024, 1, 15, 10, 0), author="Dr. Martin", priority=1, ) test_db.add(document) test_db.flush() # Créer un code DP code_dp = CodeDB( stay_id=stay.id, code="K35.8", label="Appendicite aiguë, autres et sans précision", type="dp", confidence=0.92, reasoning="Diagnostic principal basé sur le compte-rendu opératoire", referentiel_version="CIM-10 2024", status="proposed", model_name="test-model", model_digest="a" * 64, # Hash SHA-256 valide (64 caractères hexadécimaux) prompt_version="v1.0", ) test_db.add(code_dp) test_db.flush() # Créer une preuve pour le code DP evidence = EvidenceDB( code_id=code_dp.id, document_id=document.id, span_start=19, span_end=36, text="appendicite aiguë", context="Patient opéré pour appendicite aiguë. Intervention réalisée", ) test_db.add(evidence) # Créer un code CCAM code_ccam = CodeDB( stay_id=stay.id, code="HHFA015", label="Appendicectomie", type="ccam", confidence=0.95, reasoning="Acte chirurgical réalisé", referentiel_version="CCAM V81", status="proposed", model_name="test-model", model_digest="b" * 64, # Hash SHA-256 valide (64 caractères hexadécimaux) prompt_version="v1.0", ) test_db.add(code_ccam) test_db.flush() # Créer une preuve pour le code CCAM evidence_ccam = EvidenceDB( code_id=code_ccam.id, document_id=document.id, span_start=0, span_end=15, text="Patient opéré", context="Patient opéré pour appendicite aiguë", ) test_db.add(evidence_ccam) test_db.commit() return stay class TestGetCodingProposal: """Tests pour l'endpoint GET /stays/{stay_id}/coding-proposal.""" def test_get_coding_proposal_success(self, client, sample_stay): """Test récupération réussie d'une proposition de codage.""" response = client.get(f"/stays/{sample_stay.stay_id}/coding-proposal") assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["dp"] is not None assert data["dp"]["code"] == "K35.8" assert data["dp"]["confidence"] == 0.92 assert len(data["dp"]["evidence"]) == 1 assert len(data["ccam"]) == 1 assert data["ccam"][0]["code"] == "HHFA015" assert "confidence_scores" in data assert data["confidence_scores"]["dp"] == 0.92 def test_get_coding_proposal_not_found(self, client): """Test avec un séjour inexistant.""" response = client.get("/stays/NONEXISTENT/coding-proposal") assert response.status_code == 404 assert "not found" in response.json()["detail"].lower() def test_get_coding_proposal_no_codes(self, client, test_db): """Test avec un séjour sans codes proposés.""" # Créer un séjour sans codes stay = StayDB( stay_id="STAY_NO_CODES", admission_date=datetime(2024, 1, 15), discharge_date=datetime(2024, 1, 20), specialty="medecine", ) test_db.add(stay) test_db.commit() response = client.get(f"/stays/{stay.stay_id}/coding-proposal") assert response.status_code == 404 assert "no coding proposal" in response.json()["detail"].lower() class TestCorrectCode: """Tests pour l'endpoint POST /stays/{stay_id}/correct-code.""" def test_correct_code_success(self, client, sample_stay): """Test correction réussie d'un code.""" correction_data = { "stay_id": sample_stay.stay_id, "original_code": "K35.8", "corrected_code": "K35.2", "corrected_label": "Appendicite aiguë avec péritonite généralisée", "comment": "Précision du diagnostic après relecture", } response = client.post( f"/stays/{sample_stay.stay_id}/correct-code", json=correction_data, ) assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["original_code"] == "K35.8" assert data["corrected_code"] == "K35.2" assert "timestamp" in data def test_correct_code_stay_not_found(self, client): """Test correction avec séjour inexistant.""" correction_data = { "stay_id": "NONEXISTENT", "original_code": "K35.8", "corrected_code": "K35.2", } response = client.post( "/stays/NONEXISTENT/correct-code", json=correction_data, ) assert response.status_code == 404 def test_correct_code_mismatch_stay_id(self, client, sample_stay): """Test correction avec stay_id incohérent.""" correction_data = { "stay_id": "DIFFERENT_STAY", "original_code": "K35.8", "corrected_code": "K35.2", } response = client.post( f"/stays/{sample_stay.stay_id}/correct-code", json=correction_data, ) assert response.status_code == 400 assert "mismatch" in response.json()["detail"].lower() def test_correct_code_not_found(self, client, sample_stay): """Test correction d'un code inexistant.""" correction_data = { "stay_id": sample_stay.stay_id, "original_code": "Z99.9", # Code inexistant "corrected_code": "K35.2", } response = client.post( f"/stays/{sample_stay.stay_id}/correct-code", json=correction_data, ) assert response.status_code == 404 class TestValidateStay: """Tests pour l'endpoint POST /stays/{stay_id}/validate.""" def test_validate_stay_accepted(self, client, sample_stay): """Test validation acceptée d'un séjour.""" validation_data = { "stay_id": sample_stay.stay_id, "validation_status": "accepted", "comment": "Codage conforme", } response = client.post( f"/stays/{sample_stay.stay_id}/validate", json=validation_data, ) assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["validation_status"] == "accepted" assert "timestamp" in data def test_validate_stay_rejected(self, client, sample_stay): """Test validation rejetée d'un séjour.""" validation_data = { "stay_id": sample_stay.stay_id, "validation_status": "rejected", "comment": "Codes incorrects", } response = client.post( f"/stays/{sample_stay.stay_id}/validate", json=validation_data, ) assert response.status_code == 200 data = response.json() assert data["validation_status"] == "rejected" def test_validate_stay_needs_review(self, client, sample_stay): """Test validation à revoir d'un séjour.""" validation_data = { "stay_id": sample_stay.stay_id, "validation_status": "needs_review", } response = client.post( f"/stays/{sample_stay.stay_id}/validate", json=validation_data, ) assert response.status_code == 200 data = response.json() assert data["validation_status"] == "needs_review" def test_validate_stay_not_found(self, client): """Test validation avec séjour inexistant.""" validation_data = { "stay_id": "NONEXISTENT", "validation_status": "accepted", } response = client.post( "/stays/NONEXISTENT/validate", json=validation_data, ) assert response.status_code == 404 class TestAddComment: """Tests pour l'endpoint POST /stays/{stay_id}/comment.""" def test_add_comment_success(self, client, sample_stay): """Test ajout réussi d'un commentaire.""" comment_data = { "stay_id": sample_stay.stay_id, "code": "K35.8", "comment": "Vérifier la présence de péritonite", } response = client.post( f"/stays/{sample_stay.stay_id}/comment", json=comment_data, ) assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["code"] == "K35.8" assert data["comment"] == comment_data["comment"] assert "timestamp" in data def test_add_comment_stay_not_found(self, client): """Test ajout de commentaire avec séjour inexistant.""" comment_data = { "stay_id": "NONEXISTENT", "code": "K35.8", "comment": "Test", } response = client.post( "/stays/NONEXISTENT/comment", json=comment_data, ) assert response.status_code == 404 def test_add_comment_code_not_found(self, client, sample_stay): """Test ajout de commentaire pour un code inexistant.""" comment_data = { "stay_id": sample_stay.stay_id, "code": "Z99.9", # Code inexistant "comment": "Test", } response = client.post( f"/stays/{sample_stay.stay_id}/comment", json=comment_data, ) assert response.status_code == 404 class TestExportAudit: """Tests pour l'endpoint POST /stays/{stay_id}/audit/export.""" def test_export_audit_plain(self, client, sample_stay): """Test export d'audit non chiffré.""" export_data = { "stay_id": sample_stay.stay_id, "include_pii": False, "encrypt": False, } response = client.post( f"/stays/{sample_stay.stay_id}/audit/export", json=export_data, ) assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["encrypted"] is False assert "data" in data assert "export_date" in data def test_export_audit_encrypted(self, client, sample_stay): """Test export d'audit chiffré.""" export_data = { "stay_id": sample_stay.stay_id, "include_pii": False, "encrypt": True, } response = client.post( f"/stays/{sample_stay.stay_id}/audit/export", json=export_data, ) assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["encrypted"] is True assert "data" in data assert "encryption_key" in data assert "export_date" in data # Vérifier que les données sont en base64 import base64 try: base64.b64decode(data["data"]) except Exception: pytest.fail("Les données chiffrées ne sont pas en base64 valide") def test_export_audit_stay_not_found(self, client): """Test export d'audit avec séjour inexistant.""" export_data = { "stay_id": "NONEXISTENT", "include_pii": False, "encrypt": False, } response = client.post( "/stays/NONEXISTENT/audit/export", json=export_data, ) assert response.status_code == 404 class TestGetEvidence: """Tests pour l'endpoint GET /stays/{stay_id}/evidence/{code}.""" def test_get_evidence_success(self, client, sample_stay): """Test récupération réussie des preuves.""" response = client.get(f"/stays/{sample_stay.stay_id}/evidence/K35.8") assert response.status_code == 200 data = response.json() assert data["stay_id"] == sample_stay.stay_id assert data["code"] == "K35.8" assert len(data["evidences"]) == 1 evidence = data["evidences"][0] assert evidence["document_id"] == "DOC001" assert evidence["text"] == "appendicite aiguë" assert "document_link" in evidence def test_get_evidence_stay_not_found(self, client): """Test récupération de preuves avec séjour inexistant.""" response = client.get("/stays/NONEXISTENT/evidence/K35.8") assert response.status_code == 404 def test_get_evidence_code_not_found(self, client, sample_stay): """Test récupération de preuves pour un code inexistant.""" response = client.get(f"/stays/{sample_stay.stay_id}/evidence/Z99.9") assert response.status_code == 404 class TestGetDocument: """Tests pour l'endpoint GET /documents/{document_id}.""" def test_get_document_success(self, client, sample_stay): """Test récupération réussie d'un document.""" response = client.get("/documents/DOC001") assert response.status_code == 200 data = response.json() assert data["document_id"] == "DOC001" assert data["document_type"] == "cr_operatoire" assert "content" in data assert "Patient opéré" in data["content"] assert "creation_date" in data assert data["author"] == "Dr. Martin" def test_get_document_not_found(self, client): """Test récupération d'un document inexistant.""" response = client.get("/documents/NONEXISTENT") assert response.status_code == 404 class TestEvidenceNavigation: """Tests pour la navigation preuves → texte source (Exigences 10.2, 10.3).""" def test_evidence_to_document_navigation(self, client, sample_stay): """Test navigation complète depuis une preuve vers le document source.""" # 1. Récupérer les preuves pour un code evidence_response = client.get(f"/stays/{sample_stay.stay_id}/evidence/K35.8") assert evidence_response.status_code == 200 evidence_data = evidence_response.json() # Vérifier que les preuves contiennent les informations nécessaires assert len(evidence_data["evidences"]) > 0 evidence = evidence_data["evidences"][0] assert "document_id" in evidence assert "span" in evidence assert "start" in evidence["span"] assert "end" in evidence["span"] assert "document_link" in evidence # 2. Utiliser le lien pour récupérer le document document_id = evidence["document_id"] document_response = client.get(f"/documents/{document_id}") assert document_response.status_code == 200 document_data = document_response.json() # 3. Vérifier que le texte de la preuve correspond au span dans le document span_start = evidence["span"]["start"] span_end = evidence["span"]["end"] document_content = document_data["content"] extracted_text = document_content[span_start:span_end] assert extracted_text == evidence["text"] def test_evidence_contains_context(self, client, sample_stay): """Test que les preuves contiennent le contexte pour faciliter la navigation.""" response = client.get(f"/stays/{sample_stay.stay_id}/evidence/K35.8") assert response.status_code == 200 data = response.json() evidence = data["evidences"][0] assert "context" in evidence assert evidence["context"] is not None # Le contexte doit contenir le texte de la preuve assert evidence["text"] in evidence["context"] def test_multiple_evidences_navigation(self, client, test_db): """Test navigation avec plusieurs preuves pour un même code.""" # Créer un séjour avec plusieurs documents et preuves stay = StayDB( stay_id="STAY_MULTI_EVIDENCE", admission_date=datetime(2024, 1, 15), discharge_date=datetime(2024, 1, 20), specialty="medecine", ) test_db.add(stay) test_db.flush() # Document 1 doc1 = ClinicalDocumentDB( stay_id=stay.id, document_id="DOC_MULTI_1", document_type="cr_medical", content="Patient présente une hypertension artérielle essentielle.", creation_date=datetime(2024, 1, 15), priority=2, ) test_db.add(doc1) test_db.flush() # Document 2 doc2 = ClinicalDocumentDB( stay_id=stay.id, document_id="DOC_MULTI_2", document_type="biologie", content="Tension artérielle élevée confirmée.", creation_date=datetime(2024, 1, 16), priority=4, ) test_db.add(doc2) test_db.flush() # Code avec plusieurs preuves code = CodeDB( stay_id=stay.id, code="I10", label="Hypertension essentielle", type="dp", confidence=0.88, reasoning="Diagnostic confirmé par plusieurs documents", referentiel_version="CIM-10 2024", status="proposed", model_name="test-model", model_digest="test-digest", prompt_version="v1.0", ) test_db.add(code) test_db.flush() # Preuve 1 ev1 = EvidenceDB( code_id=code.id, document_id=doc1.id, span_start=21, span_end=56, text="hypertension artérielle essentielle", context="Patient présente une hypertension artérielle essentielle.", ) test_db.add(ev1) # Preuve 2 ev2 = EvidenceDB( code_id=code.id, document_id=doc2.id, span_start=0, span_end=25, text="Tension artérielle élevée", context="Tension artérielle élevée confirmée.", ) test_db.add(ev2) test_db.commit() # Récupérer les preuves response = client.get(f"/stays/{stay.stay_id}/evidence/I10") assert response.status_code == 200 data = response.json() # Vérifier qu'on a bien 2 preuves assert len(data["evidences"]) == 2 # Vérifier que chaque preuve pointe vers le bon document doc_ids = [ev["document_id"] for ev in data["evidences"]] assert "DOC_MULTI_1" in doc_ids assert "DOC_MULTI_2" in doc_ids # Vérifier la navigation vers chaque document for evidence in data["evidences"]: doc_response = client.get(f"/documents/{evidence['document_id']}") assert doc_response.status_code == 200 doc_data = doc_response.json() # Vérifier que le span correspond span_start = evidence["span"]["start"] span_end = evidence["span"]["end"] extracted = doc_data["content"][span_start:span_end] assert extracted == evidence["text"] def test_evidence_document_metadata(self, client, sample_stay): """Test que les métadonnées du document sont disponibles pour la navigation.""" response = client.get(f"/stays/{sample_stay.stay_id}/evidence/K35.8") assert response.status_code == 200 data = response.json() evidence = data["evidences"][0] # Vérifier les métadonnées assert "document_type" in evidence assert evidence["document_type"] == "cr_operatoire" # Récupérer le document complet doc_response = client.get(f"/documents/{evidence['document_id']}") assert doc_response.status_code == 200 doc_data = doc_response.json() # Vérifier que les métadonnées sont cohérentes assert doc_data["document_type"] == evidence["document_type"] assert "priority" in doc_data assert "creation_date" in doc_data class TestRootEndpoint: """Tests pour l'endpoint racine.""" def test_root_returns_html(self, client): """Test que l'endpoint racine retourne l'interface HTML.""" response = client.get("/") # Devrait retourner un fichier HTML ou une redirection assert response.status_code in [200, 307] # 200 OK ou 307 Temporary Redirect