""" Tests for Legal Document Routes — 007_legal_documents migration. Tests: Document CRUD, Version creation, Approval-Workflow (submit→approve→publish), Rejection-Flow, approval history. """ import pytest from unittest.mock import MagicMock, patch from datetime import datetime, timezone import uuid # ============================================================================ # Shared Fixtures # ============================================================================ def make_uuid(): return str(uuid.uuid4()) def make_document(type='privacy_policy', name='Datenschutzerklärung', tenant_id='test-tenant'): doc = MagicMock() doc.id = uuid.uuid4() doc.tenant_id = tenant_id doc.type = type doc.name = name doc.description = 'Test description' doc.mandatory = False doc.created_at = datetime.now(timezone.utc) doc.updated_at = None return doc def make_version(document_id=None, version='1.0', status='draft', title='Test Version'): v = MagicMock() v.id = uuid.uuid4() v.document_id = document_id or uuid.uuid4() v.version = version v.language = 'de' v.title = title v.content = '

Inhalt der Datenschutzerklärung

' v.summary = 'Kurzzusammenfassung' v.status = status v.created_by = 'admin@test.de' v.approved_by = None v.approved_at = None v.rejection_reason = None v.created_at = datetime.now(timezone.utc) v.updated_at = None return v def make_approval(version_id=None, action='created'): a = MagicMock() a.id = uuid.uuid4() a.version_id = version_id or uuid.uuid4() a.action = action a.approver = 'admin@test.de' a.comment = None a.created_at = datetime.now(timezone.utc) return a # ============================================================================ # Pydantic Schema Tests # ============================================================================ class TestDocumentCreate: def test_document_create_valid(self): from compliance.api.legal_document_routes import DocumentCreate doc = DocumentCreate( type='privacy_policy', name='Datenschutzerklärung', description='DSE für Webseite', mandatory=True, tenant_id='tenant-abc', ) assert doc.type == 'privacy_policy' assert doc.mandatory is True assert doc.tenant_id == 'tenant-abc' def test_document_create_minimal(self): from compliance.api.legal_document_routes import DocumentCreate doc = DocumentCreate(type='terms', name='AGB') assert doc.mandatory is False assert doc.tenant_id is None assert doc.description is None def test_document_create_all_types(self): from compliance.api.legal_document_routes import DocumentCreate for doc_type in ['privacy_policy', 'terms', 'cookie_policy', 'imprint', 'dpa']: doc = DocumentCreate(type=doc_type, name=f'{doc_type} document') assert doc.type == doc_type class TestVersionCreate: def test_version_create_valid(self): from compliance.api.legal_document_routes import VersionCreate doc_id = make_uuid() v = VersionCreate( document_id=doc_id, version='1.0', title='DSE Version 1.0', content='

Inhalt

', summary='Zusammenfassung', created_by='admin@test.de', ) assert v.version == '1.0' assert v.language == 'de' assert v.document_id == doc_id def test_version_create_defaults(self): from compliance.api.legal_document_routes import VersionCreate v = VersionCreate( document_id=make_uuid(), version='2.0', title='Version 2', content='Content', ) assert v.language == 'de' assert v.created_by is None assert v.summary is None def test_version_update_partial(self): from compliance.api.legal_document_routes import VersionUpdate update = VersionUpdate(title='Neuer Titel', content='Neuer Inhalt') data = update.dict(exclude_none=True) assert 'title' in data assert 'content' in data assert 'language' not in data class TestActionRequest: def test_action_request_defaults(self): from compliance.api.legal_document_routes import ActionRequest req = ActionRequest() assert req.approver is None assert req.comment is None def test_action_request_with_data(self): from compliance.api.legal_document_routes import ActionRequest req = ActionRequest(approver='dpo@company.de', comment='Alles korrekt') assert req.approver == 'dpo@company.de' assert req.comment == 'Alles korrekt' # ============================================================================ # Helper Function Tests # ============================================================================ class TestDocToResponse: def test_doc_to_response(self): from compliance.api.legal_document_routes import _doc_to_response doc = make_document() resp = _doc_to_response(doc) assert resp.id == str(doc.id) assert resp.type == 'privacy_policy' assert resp.mandatory is False def test_doc_to_response_mandatory(self): from compliance.api.legal_document_routes import _doc_to_response doc = make_document() doc.mandatory = True resp = _doc_to_response(doc) assert resp.mandatory is True class TestVersionToResponse: def test_version_to_response_draft(self): from compliance.api.legal_document_routes import _version_to_response v = make_version(status='draft') resp = _version_to_response(v) assert resp.status == 'draft' assert resp.approved_by is None assert resp.rejection_reason is None def test_version_to_response_approved(self): from compliance.api.legal_document_routes import _version_to_response v = make_version(status='approved') v.approved_by = 'dpo@company.de' v.approved_at = datetime.now(timezone.utc) resp = _version_to_response(v) assert resp.status == 'approved' assert resp.approved_by == 'dpo@company.de' def test_version_to_response_rejected(self): from compliance.api.legal_document_routes import _version_to_response v = make_version(status='rejected') v.rejection_reason = 'Inhalt unvollständig' resp = _version_to_response(v) assert resp.status == 'rejected' assert resp.rejection_reason == 'Inhalt unvollständig' # ============================================================================ # Approval Workflow Transition Tests # ============================================================================ class TestApprovalWorkflow: def test_transition_raises_on_wrong_status(self): """_transition should raise ValidationError if version is in wrong status.""" from compliance.api.legal_document_routes import _transition from compliance.domain import ValidationError as DomainValidationError mock_db = MagicMock() v = make_version(status='draft') mock_db.query.return_value.filter.return_value.first.return_value = v with pytest.raises(DomainValidationError) as exc_info: _transition(mock_db, str(v.id), ['review'], 'approved', 'approved', None, None) assert 'draft' in str(exc_info.value) def test_transition_raises_on_not_found(self): """_transition should raise NotFoundError if version not found.""" from compliance.api.legal_document_routes import _transition from compliance.domain import NotFoundError mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None with pytest.raises(NotFoundError): _transition(mock_db, make_uuid(), ['draft'], 'review', 'submitted', None, None) def test_transition_success(self): """_transition should change status and log approval.""" from compliance.api.legal_document_routes import _transition mock_db = MagicMock() v = make_version(status='draft') mock_db.query.return_value.filter.return_value.first.return_value = v result = _transition(mock_db, str(v.id), ['draft'], 'review', 'submitted', 'admin', None) assert v.status == 'review' mock_db.commit.assert_called_once() def test_full_workflow_draft_to_published(self): """Simulate the full approval workflow: draft → review → approved → published.""" from compliance.api.legal_document_routes import _transition mock_db = MagicMock() v = make_version(status='draft') mock_db.query.return_value.filter.return_value.first.return_value = v # Step 1: Submit for review _transition(mock_db, str(v.id), ['draft'], 'review', 'submitted', 'author', None) assert v.status == 'review' # Step 2: Approve mock_db.reset_mock() _transition(mock_db, str(v.id), ['review'], 'approved', 'approved', 'dpo', 'Korrekt', extra_updates={'approved_by': 'dpo', 'approved_at': datetime.now(timezone.utc)}) assert v.status == 'approved' # Step 3: Publish mock_db.reset_mock() _transition(mock_db, str(v.id), ['approved'], 'published', 'published', 'dpo', None) assert v.status == 'published' def test_rejection_flow(self): """Review → Rejected → draft (re-edit) → review again.""" from compliance.api.legal_document_routes import _transition mock_db = MagicMock() v = make_version(status='review') mock_db.query.return_value.filter.return_value.first.return_value = v # Reject _transition(mock_db, str(v.id), ['review'], 'rejected', 'rejected', 'dpo', 'Überarbeitung nötig', extra_updates={'rejection_reason': 'Überarbeitung nötig'}) assert v.status == 'rejected' # After rejection, version is editable again (draft/rejected allowed) # Re-submit for review _transition(mock_db, str(v.id), ['draft', 'rejected'], 'review', 'submitted', 'author', None) assert v.status == 'review' # ============================================================================ # Log Approval Tests # ============================================================================ class TestLogApproval: def test_log_approval_creates_entry(self): from compliance.api.legal_document_routes import _log_approval from compliance.db.legal_document_models import LegalDocumentApprovalDB mock_db = MagicMock() version_id = uuid.uuid4() entry = _log_approval(mock_db, version_id, 'approved', 'dpo@test.de', 'Gut') mock_db.add.assert_called_once() added = mock_db.add.call_args[0][0] assert isinstance(added, LegalDocumentApprovalDB) assert added.action == 'approved' assert added.approver == 'dpo@test.de' def test_log_approval_without_approver(self): from compliance.api.legal_document_routes import _log_approval from compliance.db.legal_document_models import LegalDocumentApprovalDB mock_db = MagicMock() _log_approval(mock_db, uuid.uuid4(), 'created') added = mock_db.add.call_args[0][0] assert added.approver is None assert added.comment is None # ============================================================================ # GET /documents/{id}, DELETE /documents/{id}, GET /versions/{id} # ============================================================================ class TestGetDocumentById: def test_get_document_by_id_found(self): from compliance.api.legal_document_routes import _doc_to_response doc = make_document() resp = _doc_to_response(doc) assert resp.id == str(doc.id) assert resp.type == 'privacy_policy' def test_get_document_by_id_not_found(self): """get_document raises 404 when document is missing.""" from compliance.api.legal_document_routes import _doc_to_response from fastapi import HTTPException mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None with pytest.raises(HTTPException) as exc_info: # Simulate handler logic directly doc = mock_db.query(None).filter(None).first() if not doc: raise HTTPException(status_code=404, detail="Document not found") assert exc_info.value.status_code == 404 class TestDeleteDocument: def test_delete_document(self): """delete_document calls db.delete and db.commit.""" mock_db = MagicMock() doc = make_document() mock_db.query.return_value.filter.return_value.first.return_value = doc # Simulate handler logic found = mock_db.query(None).filter(None).first() if not found: from fastapi import HTTPException raise HTTPException(status_code=404, detail="not found") mock_db.delete(found) mock_db.commit() mock_db.delete.assert_called_once_with(doc) mock_db.commit.assert_called_once() class TestGetVersionById: def test_get_version_by_id(self): from compliance.api.legal_document_routes import _version_to_response v = make_version(status='draft') resp = _version_to_response(v) assert resp.id == str(v.id) assert resp.status == 'draft' assert resp.version == '1.0' # ============================================================================ # GET /documents/{id}/versions — Array-Response-Format (Regression: Phase 3) # ============================================================================ class TestListVersionsByDocument: def test_versions_response_is_list_not_dict(self): """GET /documents/{id}/versions muss ein direktes Array zurückgeben.""" import uuid as _uuid from compliance.api.legal_document_routes import _version_to_response doc_id = _uuid.uuid4() versions = [ make_version(document_id=doc_id, version='1.0', status='draft'), make_version(document_id=doc_id, version='2.0', status='published'), ] result = [_version_to_response(v) for v in versions] assert isinstance(result, list), "Response muss Liste sein, kein Dict" assert not isinstance(result, dict) assert len(result) == 2 assert result[0].version == '1.0' assert result[1].version == '2.0' def test_versions_empty_returns_empty_list(self): """GET /documents/{id}/versions ohne Versionen → [], nicht {'versions': []}.""" result = [] # Leere Versionsliste wie die Route bei 0 Ergebnissen assert result == [] assert isinstance(result, list)