All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 37s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 18s
- TestConsentResponseFields (3 Tests): sichert ip_address + user_agent in GET /consents Response ab
- TestListVersionsByDocument (2 Tests): sichert Array-Format von GET /documents/{id}/versions ab
- 27 Tests in test_einwilligungen_routes.py, 26 in test_legal_document_routes.py, alle bestanden
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
404 lines
15 KiB
Python
404 lines
15 KiB
Python
"""
|
|
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
|
|
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.utcnow()
|
|
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 = '<p>Inhalt der Datenschutzerklärung</p>'
|
|
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.utcnow()
|
|
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.utcnow()
|
|
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='<p>Inhalt</p>',
|
|
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.utcnow()
|
|
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 HTTPException if version is in wrong status."""
|
|
from compliance.api.legal_document_routes import _transition
|
|
from fastapi import HTTPException
|
|
|
|
mock_db = MagicMock()
|
|
v = make_version(status='draft')
|
|
mock_db.query.return_value.filter.return_value.first.return_value = v
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
_transition(mock_db, str(v.id), ['review'], 'approved', 'approved', None, None)
|
|
|
|
assert exc_info.value.status_code == 400
|
|
assert 'draft' in exc_info.value.detail
|
|
|
|
def test_transition_raises_on_not_found(self):
|
|
"""_transition should raise 404 if version not found."""
|
|
from compliance.api.legal_document_routes import _transition
|
|
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:
|
|
_transition(mock_db, make_uuid(), ['draft'], 'review', 'submitted', None, None)
|
|
|
|
assert exc_info.value.status_code == 404
|
|
|
|
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.utcnow()})
|
|
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)
|