""" Tests for DSR (Data Subject Request) routes. Pattern: app.dependency_overrides[get_db] for FastAPI DI. """ import uuid import os import sys from datetime import datetime, timedelta, timezone import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from sqlalchemy import create_engine, text from sqlalchemy.orm import sessionmaker # Ensure backend dir is on path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) from classroom_engine.database import Base, get_db from compliance.db.dsr_models import ( DSRRequestDB, DSRStatusHistoryDB, DSRCommunicationDB, DSRTemplateDB, DSRTemplateVersionDB, DSRExceptionCheckDB, ) from compliance.api.dsr_routes import router as dsr_router # In-memory SQLite for testing SQLALCHEMY_DATABASE_URL = "sqlite:///./test_dsr.db" engine = create_engine(SQLALCHEMY_DATABASE_URL, connect_args={"check_same_thread": False}) TestingSessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" HEADERS = {"X-Tenant-ID": TENANT_ID} # Create a minimal test app (avoids importing main.py with its Python 3.10+ syntax issues) app = FastAPI() app.include_router(dsr_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(autouse=True) def setup_db(): """Create all tables before each test, drop after.""" Base.metadata.create_all(bind=engine) # Create sequence workaround for SQLite (no sequences) db = TestingSessionLocal() try: # SQLite doesn't have sequences; we'll mock the request number generation pass finally: db.close() yield Base.metadata.drop_all(bind=engine) @pytest.fixture def db_session(): db = TestingSessionLocal() try: yield db finally: db.close() def _create_dsr_in_db(db, **kwargs): """Helper to create a DSR directly in DB.""" now = datetime.now(timezone.utc) defaults = { "tenant_id": uuid.UUID(TENANT_ID), "request_number": f"DSR-2026-{str(uuid.uuid4())[:6].upper()}", "request_type": "access", "status": "intake", "priority": "normal", "requester_name": "Max Mustermann", "requester_email": "max@example.de", "source": "email", "received_at": now, "deadline_at": now + timedelta(days=30), "created_at": now, "updated_at": now, } defaults.update(kwargs) dsr = DSRRequestDB(**defaults) db.add(dsr) db.commit() db.refresh(dsr) return dsr # ============================================================================= # CREATE Tests # ============================================================================= class TestCreateDSR: def test_create_access_request(self, db_session): resp = client.post("/api/compliance/dsr", json={ "request_type": "access", "requester_name": "Max Mustermann", "requester_email": "max@example.de", "source": "email", "request_text": "Auskunft nach Art. 15 DSGVO", }, headers=HEADERS) # May fail on SQLite due to sequence; check for 200 or 500 if resp.status_code == 200: data = resp.json() assert data["request_type"] == "access" assert data["status"] == "intake" assert data["requester_name"] == "Max Mustermann" assert data["requester_email"] == "max@example.de" assert data["deadline_at"] is not None def test_create_erasure_request(self, db_session): resp = client.post("/api/compliance/dsr", json={ "request_type": "erasure", "requester_name": "Anna Schmidt", "requester_email": "anna@example.de", "source": "web_form", "request_text": "Bitte alle Daten loeschen", "priority": "high", }, headers=HEADERS) if resp.status_code == 200: data = resp.json() assert data["request_type"] == "erasure" assert data["priority"] == "high" def test_create_invalid_type(self): resp = client.post("/api/compliance/dsr", json={ "request_type": "invalid_type", "requester_name": "Test", "requester_email": "test@test.de", }, headers=HEADERS) assert resp.status_code == 400 def test_create_invalid_source(self): resp = client.post("/api/compliance/dsr", json={ "request_type": "access", "requester_name": "Test", "requester_email": "test@test.de", "source": "invalid_source", }, headers=HEADERS) assert resp.status_code == 400 def test_create_invalid_priority(self): resp = client.post("/api/compliance/dsr", json={ "request_type": "access", "requester_name": "Test", "requester_email": "test@test.de", "priority": "ultra", }, headers=HEADERS) assert resp.status_code == 400 def test_create_missing_name(self): resp = client.post("/api/compliance/dsr", json={ "request_type": "access", "requester_email": "test@test.de", }, headers=HEADERS) assert resp.status_code == 422 def test_create_missing_email(self): resp = client.post("/api/compliance/dsr", json={ "request_type": "access", "requester_name": "Test", }, headers=HEADERS) assert resp.status_code == 422 # ============================================================================= # LIST Tests # ============================================================================= class TestListDSR: def test_list_empty(self): resp = client.get("/api/compliance/dsr", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["requests"] == [] assert data["total"] == 0 def test_list_with_data(self, db_session): _create_dsr_in_db(db_session, request_type="access") _create_dsr_in_db(db_session, request_type="erasure") resp = client.get("/api/compliance/dsr", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["total"] == 2 assert len(data["requests"]) == 2 def test_list_filter_by_status(self, db_session): _create_dsr_in_db(db_session, status="intake") _create_dsr_in_db(db_session, status="processing") _create_dsr_in_db(db_session, status="completed") resp = client.get("/api/compliance/dsr?status=intake", headers=HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 1 def test_list_filter_by_type(self, db_session): _create_dsr_in_db(db_session, request_type="access") _create_dsr_in_db(db_session, request_type="erasure") resp = client.get("/api/compliance/dsr?request_type=erasure", headers=HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 1 def test_list_filter_by_priority(self, db_session): _create_dsr_in_db(db_session, priority="high") _create_dsr_in_db(db_session, priority="normal") resp = client.get("/api/compliance/dsr?priority=high", headers=HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 1 def test_list_search(self, db_session): _create_dsr_in_db(db_session, requester_name="Max Mustermann", requester_email="max@example.de") _create_dsr_in_db(db_session, requester_name="Anna Schmidt", requester_email="anna@example.de") resp = client.get("/api/compliance/dsr?search=Anna", headers=HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 1 def test_list_pagination(self, db_session): for i in range(5): _create_dsr_in_db(db_session) resp = client.get("/api/compliance/dsr?limit=2&offset=0", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["total"] == 5 assert len(data["requests"]) == 2 def test_list_overdue_only(self, db_session): _create_dsr_in_db(db_session, deadline_at=datetime.now(timezone.utc) - timedelta(days=5), status="processing") _create_dsr_in_db(db_session, deadline_at=datetime.now(timezone.utc) + timedelta(days=20), status="processing") resp = client.get("/api/compliance/dsr?overdue_only=true", headers=HEADERS) assert resp.status_code == 200 assert resp.json()["total"] == 1 # ============================================================================= # GET DETAIL Tests # ============================================================================= class TestGetDSR: def test_get_existing(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.get(f"/api/compliance/dsr/{dsr.id}", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["id"] == str(dsr.id) assert data["requester_name"] == "Max Mustermann" def test_get_nonexistent(self): fake_id = str(uuid.uuid4()) resp = client.get(f"/api/compliance/dsr/{fake_id}", headers=HEADERS) assert resp.status_code == 404 def test_get_invalid_id(self): resp = client.get("/api/compliance/dsr/not-a-uuid", headers=HEADERS) assert resp.status_code == 400 # ============================================================================= # UPDATE Tests # ============================================================================= class TestUpdateDSR: def test_update_priority(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.put(f"/api/compliance/dsr/{dsr.id}", json={ "priority": "high", }, headers=HEADERS) assert resp.status_code == 200 assert resp.json()["priority"] == "high" def test_update_notes(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.put(f"/api/compliance/dsr/{dsr.id}", json={ "notes": "Test note", "internal_notes": "Internal note", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["notes"] == "Test note" assert data["internal_notes"] == "Internal note" def test_update_invalid_priority(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.put(f"/api/compliance/dsr/{dsr.id}", json={ "priority": "ultra", }, headers=HEADERS) assert resp.status_code == 400 # ============================================================================= # DELETE Tests # ============================================================================= class TestDeleteDSR: def test_cancel_dsr(self, db_session): dsr = _create_dsr_in_db(db_session, status="intake") resp = client.delete(f"/api/compliance/dsr/{dsr.id}", headers=HEADERS) assert resp.status_code == 200 # Verify status is cancelled resp2 = client.get(f"/api/compliance/dsr/{dsr.id}", headers=HEADERS) assert resp2.json()["status"] == "cancelled" def test_cancel_already_completed(self, db_session): dsr = _create_dsr_in_db(db_session, status="completed") resp = client.delete(f"/api/compliance/dsr/{dsr.id}", headers=HEADERS) assert resp.status_code == 400 # ============================================================================= # STATS Tests # ============================================================================= class TestDSRStats: def test_stats_empty(self): resp = client.get("/api/compliance/dsr/stats", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["total"] == 0 def test_stats_with_data(self, db_session): _create_dsr_in_db(db_session, status="intake", request_type="access") _create_dsr_in_db(db_session, status="processing", request_type="erasure") _create_dsr_in_db(db_session, status="completed", request_type="access", completed_at=datetime.now(timezone.utc)) resp = client.get("/api/compliance/dsr/stats", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["total"] == 3 assert data["by_status"]["intake"] == 1 assert data["by_status"]["processing"] == 1 assert data["by_status"]["completed"] == 1 assert data["by_type"]["access"] == 2 assert data["by_type"]["erasure"] == 1 # ============================================================================= # WORKFLOW Tests # ============================================================================= class TestDSRWorkflow: def test_change_status(self, db_session): dsr = _create_dsr_in_db(db_session, status="intake") resp = client.post(f"/api/compliance/dsr/{dsr.id}/status", json={ "status": "identity_verification", "comment": "ID angefragt", }, headers=HEADERS) assert resp.status_code == 200 assert resp.json()["status"] == "identity_verification" def test_change_status_invalid(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.post(f"/api/compliance/dsr/{dsr.id}/status", json={ "status": "invalid_status", }, headers=HEADERS) assert resp.status_code == 400 def test_verify_identity(self, db_session): dsr = _create_dsr_in_db(db_session, status="identity_verification") resp = client.post(f"/api/compliance/dsr/{dsr.id}/verify-identity", json={ "method": "id_document", "notes": "Personalausweis geprueft", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["identity_verified"] is True assert data["verification_method"] == "id_document" assert data["status"] == "processing" def test_assign_dsr(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.post(f"/api/compliance/dsr/{dsr.id}/assign", json={ "assignee_id": "DSB Mueller", }, headers=HEADERS) assert resp.status_code == 200 assert resp.json()["assigned_to"] == "DSB Mueller" def test_extend_deadline(self, db_session): dsr = _create_dsr_in_db(db_session, status="processing") resp = client.post(f"/api/compliance/dsr/{dsr.id}/extend", json={ "reason": "Komplexe Anfrage", "days": 60, }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["extended_deadline_at"] is not None assert data["extension_reason"] == "Komplexe Anfrage" def test_extend_deadline_closed_dsr(self, db_session): dsr = _create_dsr_in_db(db_session, status="completed") resp = client.post(f"/api/compliance/dsr/{dsr.id}/extend", json={ "reason": "Test", }, headers=HEADERS) assert resp.status_code == 400 def test_complete_dsr(self, db_session): dsr = _create_dsr_in_db(db_session, status="processing") resp = client.post(f"/api/compliance/dsr/{dsr.id}/complete", json={ "summary": "Auskunft erteilt", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["status"] == "completed" assert data["completed_at"] is not None def test_complete_already_completed(self, db_session): dsr = _create_dsr_in_db(db_session, status="completed") resp = client.post(f"/api/compliance/dsr/{dsr.id}/complete", json={ "summary": "Nochmal", }, headers=HEADERS) assert resp.status_code == 400 def test_reject_dsr(self, db_session): dsr = _create_dsr_in_db(db_session, status="processing") resp = client.post(f"/api/compliance/dsr/{dsr.id}/reject", json={ "reason": "Unberechtigt", "legal_basis": "Art. 17(3)(b)", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["status"] == "rejected" assert data["rejection_reason"] == "Unberechtigt" assert data["rejection_legal_basis"] == "Art. 17(3)(b)" # ============================================================================= # HISTORY & COMMUNICATIONS Tests # ============================================================================= class TestDSRHistory: def test_get_history(self, db_session): dsr = _create_dsr_in_db(db_session) # Add a history entry entry = DSRStatusHistoryDB( tenant_id=uuid.UUID(TENANT_ID), dsr_id=dsr.id, previous_status="intake", new_status="processing", changed_by="admin", comment="Test", ) db_session.add(entry) db_session.commit() resp = client.get(f"/api/compliance/dsr/{dsr.id}/history", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert len(data) == 1 assert data[0]["new_status"] == "processing" class TestDSRCommunications: def test_send_communication(self, db_session): dsr = _create_dsr_in_db(db_session) resp = client.post(f"/api/compliance/dsr/{dsr.id}/communicate", json={ "communication_type": "outgoing", "channel": "email", "subject": "Eingangsbestaetigung", "content": "Ihre Anfrage wurde erhalten.", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["channel"] == "email" assert data["sent_at"] is not None def test_get_communications(self, db_session): dsr = _create_dsr_in_db(db_session) comm = DSRCommunicationDB( tenant_id=uuid.UUID(TENANT_ID), dsr_id=dsr.id, communication_type="outgoing", channel="email", content="Test", ) db_session.add(comm) db_session.commit() resp = client.get(f"/api/compliance/dsr/{dsr.id}/communications", headers=HEADERS) assert resp.status_code == 200 assert len(resp.json()) == 1 # ============================================================================= # EXCEPTION CHECKS Tests # ============================================================================= class TestExceptionChecks: def test_init_exception_checks(self, db_session): dsr = _create_dsr_in_db(db_session, request_type="erasure") resp = client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert len(data) == 5 assert data[0]["check_code"] == "art17_3_a" def test_init_exception_checks_not_erasure(self, db_session): dsr = _create_dsr_in_db(db_session, request_type="access") resp = client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) assert resp.status_code == 400 def test_init_exception_checks_already_initialized(self, db_session): dsr = _create_dsr_in_db(db_session, request_type="erasure") # First init client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) # Second init should fail resp = client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) assert resp.status_code == 400 def test_update_exception_check(self, db_session): dsr = _create_dsr_in_db(db_session, request_type="erasure") init_resp = client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) checks = init_resp.json() check_id = checks[0]["id"] resp = client.put(f"/api/compliance/dsr/{dsr.id}/exception-checks/{check_id}", json={ "applies": True, "notes": "Aufbewahrungspflicht nach HGB", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["applies"] is True assert data["notes"] == "Aufbewahrungspflicht nach HGB" def test_get_exception_checks(self, db_session): dsr = _create_dsr_in_db(db_session, request_type="erasure") client.post(f"/api/compliance/dsr/{dsr.id}/exception-checks/init", headers=HEADERS) resp = client.get(f"/api/compliance/dsr/{dsr.id}/exception-checks", headers=HEADERS) assert resp.status_code == 200 assert len(resp.json()) == 5 # ============================================================================= # DEADLINE PROCESSING Tests # ============================================================================= class TestDeadlineProcessing: def test_process_deadlines_empty(self): resp = client.post("/api/compliance/dsr/deadlines/process", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["processed"] == 0 def test_process_deadlines_with_overdue(self, db_session): _create_dsr_in_db(db_session, status="processing", deadline_at=datetime.now(timezone.utc) - timedelta(days=5)) _create_dsr_in_db(db_session, status="processing", deadline_at=datetime.now(timezone.utc) + timedelta(days=20)) resp = client.post("/api/compliance/dsr/deadlines/process", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["processed"] == 1 # ============================================================================= # TEMPLATE Tests # ============================================================================= class TestDSRTemplates: def test_get_templates(self, db_session): t = DSRTemplateDB( tenant_id=uuid.UUID(TENANT_ID), name="Eingangsbestaetigung", template_type="receipt", language="de", ) db_session.add(t) db_session.commit() resp = client.get("/api/compliance/dsr/templates", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 def test_get_published_templates(self, db_session): t = DSRTemplateDB( tenant_id=uuid.UUID(TENANT_ID), name="Test", template_type="receipt", language="de", is_active=True, ) db_session.add(t) db_session.commit() db_session.refresh(t) v = DSRTemplateVersionDB( template_id=t.id, version="1.0", subject="Bestaetigung", body_html="
Test
", status="published", published_at=datetime.now(timezone.utc), ) db_session.add(v) db_session.commit() resp = client.get("/api/compliance/dsr/templates/published", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert len(data) >= 1 assert data[0]["latest_version"] is not None def test_create_template_version(self, db_session): t = DSRTemplateDB( tenant_id=uuid.UUID(TENANT_ID), name="Test", template_type="receipt", language="de", ) db_session.add(t) db_session.commit() db_session.refresh(t) resp = client.post(f"/api/compliance/dsr/templates/{t.id}/versions", json={ "version": "1.0", "subject": "Bestaetigung {{referenceNumber}}", "body_html": "Ihre Anfrage wurde erhalten.
", }, headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["version"] == "1.0" assert data["status"] == "draft" def test_publish_template_version(self, db_session): t = DSRTemplateDB( tenant_id=uuid.UUID(TENANT_ID), name="Test", template_type="receipt", language="de", ) db_session.add(t) db_session.commit() db_session.refresh(t) v = DSRTemplateVersionDB( template_id=t.id, version="1.0", subject="Test", body_html="Test
", status="draft", ) db_session.add(v) db_session.commit() db_session.refresh(v) resp = client.put(f"/api/compliance/dsr/template-versions/{v.id}/publish", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert data["status"] == "published" assert data["published_at"] is not None def test_get_template_versions(self, db_session): t = DSRTemplateDB( tenant_id=uuid.UUID(TENANT_ID), name="Test", template_type="receipt", language="de", ) db_session.add(t) db_session.commit() db_session.refresh(t) v = DSRTemplateVersionDB( template_id=t.id, version="1.0", subject="V1", body_html="V1
", ) db_session.add(v) db_session.commit() resp = client.get(f"/api/compliance/dsr/templates/{t.id}/versions", headers=HEADERS) assert resp.status_code == 200 assert len(resp.json()) == 1 def test_get_template_versions_not_found(self): fake_id = str(uuid.uuid4()) resp = client.get(f"/api/compliance/dsr/templates/{fake_id}/versions", headers=HEADERS) assert resp.status_code == 404 class TestDSRExport: """Tests for DSR export endpoint.""" def test_export_csv_empty(self): resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS) assert resp.status_code == 200 assert "text/csv" in resp.headers.get("content-type", "") lines = resp.text.strip().split("\n") assert len(lines) == 1 # Header only assert "Referenznummer" in lines[0] assert "Zugewiesen" in lines[0] def test_export_csv_with_data(self): # Create a DSR first body = { "request_type": "access", "requester_name": "Export Test", "requester_email": "export@example.de", "source": "email", } create_resp = client.post("/api/compliance/dsr", json=body, headers=HEADERS) assert create_resp.status_code == 200 resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS) assert resp.status_code == 200 lines = resp.text.strip().split("\n") assert len(lines) >= 2 # Header + at least 1 data row # Check data row contains our test data assert "Export Test" in lines[1] assert "export@example.de" in lines[1] assert "access" in lines[1] def test_export_json(self): resp = client.get("/api/compliance/dsr/export?format=json", headers=HEADERS) assert resp.status_code == 200 data = resp.json() assert "exported_at" in data assert "total" in data assert "requests" in data assert isinstance(data["requests"], list) def test_export_invalid_format(self): resp = client.get("/api/compliance/dsr/export?format=xml", headers=HEADERS) assert resp.status_code == 422 # Validation error