Files
breakpilot-compliance/backend-compliance/tests/test_legal_document_routes.py
Sharang Parnerkar 3320ef94fc refactor: phase 0 guardrails + phase 1 step 2 (models.py split)
Squash of branch refactor/phase0-guardrails-and-models-split — 4 commits,
81 files, 173/173 pytest green, OpenAPI contract preserved (360 paths /
484 operations).

## Phase 0 — Architecture guardrails

Three defense-in-depth layers to keep the architecture rules enforced
regardless of who opens Claude Code in this repo:

  1. .claude/settings.json PreToolUse hook on Write/Edit blocks any file
     that would exceed the 500-line hard cap. Auto-loads in every Claude
     session in this repo.
  2. scripts/githooks/pre-commit (install via scripts/install-hooks.sh)
     enforces the LOC cap locally, freezes migrations/ without
     [migration-approved], and protects guardrail files without
     [guardrail-change].
  3. .gitea/workflows/ci.yaml gains loc-budget + guardrail-integrity +
     sbom-scan (syft+grype) jobs, adds mypy --strict for the new Python
     packages (compliance/{services,repositories,domain,schemas}), and
     tsc --noEmit for admin-compliance + developer-portal.

Per-language conventions documented in AGENTS.python.md, AGENTS.go.md,
AGENTS.typescript.md at the repo root — layering, tooling, and explicit
"what you may NOT do" lists. Root CLAUDE.md is prepended with the six
non-negotiable rules. Each of the 10 services gets a README.md.

scripts/check-loc.sh enforces soft 300 / hard 500 and surfaces the
current baseline of 205 hard + 161 soft violations so Phases 1-4 can
drain it incrementally. CI gates only CHANGED files in PRs so the
legacy baseline does not block unrelated work.

## Deprecation sweep

47 files. Pydantic V1 regex= -> pattern= (2 sites), class Config ->
ConfigDict in source_policy_router.py (schemas.py intentionally skipped;
it is the Phase 1 Step 3 split target). datetime.utcnow() ->
datetime.now(timezone.utc) everywhere including SQLAlchemy default=
callables. All DB columns already declare timezone=True, so this is a
latent-bug fix at the Python side, not a schema change.

DeprecationWarning count dropped from 158 to 35.

## Phase 1 Step 1 — Contract test harness

tests/contracts/test_openapi_baseline.py diffs the live FastAPI /openapi.json
against tests/contracts/openapi.baseline.json on every test run. Fails on
removed paths, removed status codes, or new required request body fields.
Regenerate only via tests/contracts/regenerate_baseline.py after a
consumer-updated contract change. This is the safety harness for all
subsequent refactor commits.

## Phase 1 Step 2 — models.py split (1466 -> 85 LOC shim)

compliance/db/models.py is decomposed into seven sibling aggregate modules
following the existing repo pattern (dsr_models.py, vvt_models.py, ...):

  regulation_models.py       (134) — Regulation, Requirement
  control_models.py          (279) — Control, Mapping, Evidence, Risk
  ai_system_models.py        (141) — AISystem, AuditExport
  service_module_models.py   (176) — ServiceModule, ModuleRegulation, ModuleRisk
  audit_session_models.py    (177) — AuditSession, AuditSignOff
  isms_governance_models.py  (323) — ISMSScope, Context, Policy, Objective, SoA
  isms_audit_models.py       (468) — Finding, CAPA, MgmtReview, InternalAudit,
                                     AuditTrail, Readiness

models.py becomes an 85-line re-export shim in dependency order so
existing imports continue to work unchanged. Schema is byte-identical:
__tablename__, column definitions, relationship strings, back_populates,
cascade directives all preserved.

All new sibling files are under the 500-line hard cap; largest is
isms_audit_models.py at 468. No file in compliance/db/ now exceeds
the hard cap.

## Phase 1 Step 3 — infrastructure only

backend-compliance/compliance/{schemas,domain,repositories}/ packages
are created as landing zones with docstrings. compliance/domain/
exports DomainError / NotFoundError / ConflictError / ValidationError /
PermissionError — the base classes services will use to raise
domain-level errors instead of HTTPException.

PHASE1_RUNBOOK.md at backend-compliance/PHASE1_RUNBOOK.md documents
the nine-step execution plan for Phase 1: snapshot baseline,
characterization tests, split models.py (this commit), split schemas.py
(next), extract services, extract repositories, mypy --strict, coverage.

## Verification

  backend-compliance/.venv-phase1: uv python install 3.12 + pip -r requirements.txt
  PYTHONPATH=. pytest compliance/tests/ tests/contracts/
  -> 173 passed, 0 failed, 35 warnings, OpenAPI 360/484 unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:18:29 +02:00

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, 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 = '<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.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='<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.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 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.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)