""" Integration tests for compliance audit session & sign-off routes. Phase 1 Step 4 follow-up. The legacy ``compliance/tests/test_audit_routes.py`` contains placeholder tests that only assert on request-body shape — they do not exercise the handler functions. This module uses a real FastAPI TestClient against a sqlite-backed app so that handler logic, service delegation, domain error translation, and response serialization are all covered end-to-end. Covers: - POST/GET/PUT/DELETE /audit/sessions (and lifecycle transitions) - GET /audit/checklist/{session_id} (pagination + filters) - PUT /audit/checklist/{session_id}/items/{requirement_id}/sign-off - GET /audit/checklist/{session_id}/items/{requirement_id} - Error cases: 404 (not found), 409 (invalid state transition), 400 (bad input) """ import os import sys import uuid import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import StaticPool sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from classroom_engine.database import Base, get_db # noqa: E402 from compliance.api.audit_routes import router as audit_router # noqa: E402 from compliance.db.models import ( # noqa: E402 ControlDB, ControlDomainEnum, ControlStatusEnum, ControlTypeEnum, RegulationDB, RegulationTypeEnum, RequirementDB, ) engine = create_engine( "sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool, ) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) app = FastAPI() app.include_router(audit_router, prefix="/api/compliance") def override_get_db(): db = TestingSessionLocal() try: yield db finally: db.close() app.dependency_overrides[get_db] = override_get_db client = TestClient(app) @pytest.fixture(scope="module", autouse=True) def _schema(): Base.metadata.create_all(bind=engine) yield Base.metadata.drop_all(bind=engine) @pytest.fixture(autouse=True) def _wipe_data(): """Wipe all rows between tests without recreating the schema.""" yield with engine.begin() as conn: for table in reversed(Base.metadata.sorted_tables): conn.execute(table.delete()) # ============================================================================ # Fixtures # ============================================================================ @pytest.fixture def seeded_requirements(): """Seed a regulation + 3 requirements so audit sessions have scope.""" db = TestingSessionLocal() try: reg = RegulationDB( id=str(uuid.uuid4()), code="GDPR", name="GDPR", regulation_type=RegulationTypeEnum.EU_REGULATION, ) db.add(reg) db.flush() req_ids = [] for i in range(3): req = RequirementDB( id=str(uuid.uuid4()), regulation_id=reg.id, article=f"Art. {i + 1}", title=f"Requirement {i + 1}", description=f"Desc {i + 1}", implementation_status="not_started", priority=2, ) db.add(req) req_ids.append(req.id) db.commit() yield {"regulation_id": reg.id, "requirement_ids": req_ids} finally: db.close() def _create_session(name="Test Audit", codes=None): r = client.post( "/api/compliance/audit/sessions", json={ "name": name, "description": "Integration test", "auditor_name": "Dr. Test", "auditor_email": "test@example.com", "regulation_codes": codes, }, ) assert r.status_code == 200, r.text return r.json() # ============================================================================ # Session lifecycle # ============================================================================ class TestSessionCreate: def test_create_session_without_scope_ok(self): r = client.post( "/api/compliance/audit/sessions", json={"name": "No scope", "auditor_name": "Someone"}, ) assert r.status_code == 200 body = r.json() assert body["name"] == "No scope" assert body["status"] == "draft" assert body["total_items"] == 0 assert body["completion_percentage"] == 0.0 def test_create_session_with_regulation_filter_counts_requirements( self, seeded_requirements ): body = _create_session(codes=["GDPR"]) assert body["total_items"] == 3 assert body["regulation_ids"] == ["GDPR"] class TestSessionList: def test_list_empty(self): r = client.get("/api/compliance/audit/sessions") assert r.status_code == 200 assert r.json() == [] def test_list_filters_by_status(self): a = _create_session("A") _create_session("B") # Start one -> in_progress client.put(f"/api/compliance/audit/sessions/{a['id']}/start") r = client.get("/api/compliance/audit/sessions?status=draft") assert r.status_code == 200 assert len(r.json()) == 1 assert r.json()[0]["name"] == "B" def test_list_invalid_status_returns_400(self): r = client.get("/api/compliance/audit/sessions?status=bogus") assert r.status_code == 400 assert "Invalid status" in r.json()["detail"] class TestSessionGet: def test_get_not_found_returns_404(self): r = client.get("/api/compliance/audit/sessions/missing") assert r.status_code == 404 def test_get_existing_returns_details_with_stats(self, seeded_requirements): s = _create_session(codes=["GDPR"]) r = client.get(f"/api/compliance/audit/sessions/{s['id']}") assert r.status_code == 200 body = r.json() assert body["id"] == s["id"] assert body["statistics"]["total"] == 3 assert body["statistics"]["pending"] == 3 class TestSessionTransitions: def test_start_from_draft_ok(self): s = _create_session() r = client.put(f"/api/compliance/audit/sessions/{s['id']}/start") assert r.status_code == 200 assert r.json()["status"] == "in_progress" def test_start_from_completed_returns_409(self): s = _create_session() client.put(f"/api/compliance/audit/sessions/{s['id']}/start") client.put(f"/api/compliance/audit/sessions/{s['id']}/complete") r = client.put(f"/api/compliance/audit/sessions/{s['id']}/start") assert r.status_code == 409 def test_complete_from_draft_returns_409(self): s = _create_session() r = client.put(f"/api/compliance/audit/sessions/{s['id']}/complete") assert r.status_code == 409 def test_full_lifecycle_draft_inprogress_completed_archived(self): s = _create_session() assert client.put(f"/api/compliance/audit/sessions/{s['id']}/start").status_code == 200 assert client.put(f"/api/compliance/audit/sessions/{s['id']}/complete").status_code == 200 assert client.put(f"/api/compliance/audit/sessions/{s['id']}/archive").status_code == 200 r = client.get(f"/api/compliance/audit/sessions/{s['id']}") assert r.json()["status"] == "archived" def test_archive_from_inprogress_returns_409(self): s = _create_session() client.put(f"/api/compliance/audit/sessions/{s['id']}/start") r = client.put(f"/api/compliance/audit/sessions/{s['id']}/archive") assert r.status_code == 409 class TestSessionDelete: def test_delete_draft_ok(self): s = _create_session() r = client.delete(f"/api/compliance/audit/sessions/{s['id']}") assert r.status_code == 200 assert client.get(f"/api/compliance/audit/sessions/{s['id']}").status_code == 404 def test_delete_in_progress_returns_409(self): s = _create_session() client.put(f"/api/compliance/audit/sessions/{s['id']}/start") r = client.delete(f"/api/compliance/audit/sessions/{s['id']}") assert r.status_code == 409 def test_delete_missing_returns_404(self): r = client.delete("/api/compliance/audit/sessions/missing") assert r.status_code == 404 # ============================================================================ # Checklist & sign-off # ============================================================================ class TestChecklist: def test_checklist_returns_paginated_items(self, seeded_requirements): s = _create_session(codes=["GDPR"]) r = client.get(f"/api/compliance/audit/checklist/{s['id']}?page=1&page_size=2") assert r.status_code == 200 body = r.json() assert len(body["items"]) == 2 assert body["pagination"]["total"] == 3 assert body["pagination"]["has_next"] is True assert body["pagination"]["has_prev"] is False assert body["statistics"]["pending"] == 3 def test_checklist_session_not_found_returns_404(self): r = client.get("/api/compliance/audit/checklist/nope") assert r.status_code == 404 def test_checklist_search_filters_by_title(self, seeded_requirements): s = _create_session(codes=["GDPR"]) r = client.get( f"/api/compliance/audit/checklist/{s['id']}?search=Requirement 2" ) assert r.status_code == 200 titles = [i["title"] for i in r.json()["items"]] assert titles == ["Requirement 2"] class TestSignOff: def test_sign_off_creates_record_and_auto_starts_session( self, seeded_requirements ): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] r = client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "compliant", "notes": "all good", "sign": False}, ) assert r.status_code == 200 assert r.json()["result"] == "compliant" # Session auto-starts on first sign-off got = client.get(f"/api/compliance/audit/sessions/{s['id']}").json() assert got["status"] == "in_progress" assert got["statistics"]["compliant"] == 1 assert got["statistics"]["pending"] == 2 def test_sign_off_with_signature_creates_hash(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] r = client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "compliant", "sign": True}, ) body = r.json() assert body["is_signed"] is True assert body["signature_hash"] and len(body["signature_hash"]) == 64 assert body["signed_by"] == "Dr. Test" def test_sign_off_update_existing_record_flips_counts(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "compliant"}, ) client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "non_compliant"}, ) stats = client.get(f"/api/compliance/audit/sessions/{s['id']}").json()["statistics"] assert stats["compliant"] == 0 assert stats["non_compliant"] == 1 def test_sign_off_invalid_result_returns_400(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] r = client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "bogus"}, ) assert r.status_code == 400 assert "Invalid result" in r.json()["detail"] def test_sign_off_missing_requirement_returns_404(self, seeded_requirements): s = _create_session(codes=["GDPR"]) r = client.put( f"/api/compliance/audit/checklist/{s['id']}/items/nope/sign-off", json={"result": "compliant"}, ) assert r.status_code == 404 def test_sign_off_on_completed_session_returns_409(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] client.put(f"/api/compliance/audit/sessions/{s['id']}/start") client.put(f"/api/compliance/audit/sessions/{s['id']}/complete") r = client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "compliant"}, ) assert r.status_code == 409 def test_get_sign_off_returns_404_when_missing(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] r = client.get( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}" ) assert r.status_code == 404 def test_get_sign_off_returns_existing(self, seeded_requirements): s = _create_session(codes=["GDPR"]) req_id = seeded_requirements["requirement_ids"][0] client.put( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}/sign-off", json={"result": "compliant", "notes": "ok"}, ) r = client.get( f"/api/compliance/audit/checklist/{s['id']}/items/{req_id}" ) assert r.status_code == 200 assert r.json()["result"] == "compliant" assert r.json()["notes"] == "ok"