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 32s
CI / test-python-document-crawler (push) Successful in 21s
CI / test-python-dsms-gateway (push) Successful in 18s
Implementiert MIT-lizenzierte DSGVO-Templates (DSE, Impressum, AGB) in der eigenen PostgreSQL-Datenbank statt KLAUSUR_SERVICE-Abhängigkeit. - Migration 018: compliance_legal_templates Tabelle + 3 Seed-Templates - Routes: GET/POST/PUT/DELETE /legal-templates + /status + /sources - Registriert im bestehenden compliance catch-all Proxy (kein neuer Proxy) - searchTemplates.ts: eigenes Backend als Primary, RAG bleibt Fallback - ServiceMode-Banner: KLAUSUR_SERVICE-Referenz entfernt - Tests: 25 Python + 3 Vitest — alle grün Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
356 lines
14 KiB
Python
356 lines
14 KiB
Python
"""Tests for Legal Template routes and schemas (legal_template_routes.py)."""
|
|
|
|
import json
|
|
import pytest
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
from fastapi.testclient import TestClient
|
|
from fastapi import FastAPI
|
|
|
|
from compliance.api.legal_template_routes import (
|
|
LegalTemplateCreate,
|
|
LegalTemplateUpdate,
|
|
_row_to_dict,
|
|
_get_tenant_id,
|
|
DEFAULT_TENANT_ID,
|
|
VALID_DOCUMENT_TYPES,
|
|
VALID_STATUSES,
|
|
router,
|
|
)
|
|
from classroom_engine.database import get_db
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
DEFAULT_TENANT = DEFAULT_TENANT_ID
|
|
TEMPLATE_ID = "ffffffff-0001-0001-0001-000000000001"
|
|
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
|
|
|
|
DSE_PLACEHOLDERS = [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{CONTACT_EMAIL}}", "{{VERSION_DATE}}"
|
|
]
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
def make_template_row(overrides=None):
|
|
data = {
|
|
"id": TEMPLATE_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"document_type": "privacy_policy",
|
|
"title": "Datenschutzerklärung (DSGVO-konform)",
|
|
"description": "Vollständige DSE gemäß DSGVO Art. 13/14",
|
|
"content": "# Datenschutzerklärung\n\n## 1. Verantwortlicher\n\n{{COMPANY_NAME}}",
|
|
"placeholders": DSE_PLACEHOLDERS,
|
|
"language": "de",
|
|
"jurisdiction": "DE",
|
|
"license_id": "mit",
|
|
"license_name": "MIT License",
|
|
"source_name": "BreakPilot Compliance",
|
|
"attribution_required": False,
|
|
"is_complete_document": True,
|
|
"version": "1.0.0",
|
|
"status": "published",
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
def make_db_mock():
|
|
db = MagicMock()
|
|
return db
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSchemas
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSchemas:
|
|
def test_create_schema_defaults(self):
|
|
"""LegalTemplateCreate sets sensible defaults."""
|
|
payload = LegalTemplateCreate(
|
|
document_type="privacy_policy",
|
|
title="Test DSE",
|
|
content="# Test"
|
|
)
|
|
assert payload.language == "de"
|
|
assert payload.jurisdiction == "DE"
|
|
assert payload.license_id == "mit"
|
|
assert payload.license_name == "MIT License"
|
|
assert payload.source_name == "BreakPilot Compliance"
|
|
assert payload.attribution_required is False
|
|
assert payload.is_complete_document is True
|
|
assert payload.version == "1.0.0"
|
|
assert payload.status == "published"
|
|
|
|
def test_create_schema_with_placeholders(self):
|
|
"""LegalTemplateCreate accepts placeholder list."""
|
|
payload = LegalTemplateCreate(
|
|
document_type="impressum",
|
|
title="Impressum",
|
|
content="# Impressum\n{{COMPANY_NAME}}",
|
|
placeholders=["{{COMPANY_NAME}}", "{{CEO_NAME}}"]
|
|
)
|
|
assert len(payload.placeholders) == 2
|
|
assert "{{COMPANY_NAME}}" in payload.placeholders
|
|
|
|
def test_update_schema_all_optional(self):
|
|
"""LegalTemplateUpdate: all fields optional."""
|
|
payload = LegalTemplateUpdate()
|
|
d = payload.model_dump(exclude_unset=True)
|
|
assert d == {}
|
|
|
|
def test_update_schema_partial(self):
|
|
"""LegalTemplateUpdate partial: only set fields serialized."""
|
|
payload = LegalTemplateUpdate(status="archived", title="Neue DSE")
|
|
d = payload.model_dump(exclude_unset=True)
|
|
assert d == {"status": "archived", "title": "Neue DSE"}
|
|
|
|
def test_valid_document_types_constant(self):
|
|
"""VALID_DOCUMENT_TYPES contains the 3 expected types."""
|
|
assert "privacy_policy" in VALID_DOCUMENT_TYPES
|
|
assert "terms_of_service" in VALID_DOCUMENT_TYPES
|
|
assert "impressum" in VALID_DOCUMENT_TYPES
|
|
assert len(VALID_DOCUMENT_TYPES) == 3
|
|
|
|
def test_valid_statuses_constant(self):
|
|
"""VALID_STATUSES contains expected values."""
|
|
assert "published" in VALID_STATUSES
|
|
assert "draft" in VALID_STATUSES
|
|
assert "archived" in VALID_STATUSES
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateDB
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateDB:
|
|
def test_row_to_dict_converts_datetime(self):
|
|
"""_row_to_dict converts datetime to ISO string."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert result["created_at"] == "2024-01-01T00:00:00"
|
|
assert result["updated_at"] == "2024-01-01T00:00:00"
|
|
|
|
def test_row_to_dict_preserves_strings(self):
|
|
"""_row_to_dict preserves string fields unchanged."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert result["title"] == "Datenschutzerklärung (DSGVO-konform)"
|
|
assert result["license_id"] == "mit"
|
|
assert result["document_type"] == "privacy_policy"
|
|
|
|
def test_row_to_dict_preserves_list(self):
|
|
"""_row_to_dict preserves list fields (placeholders)."""
|
|
row = make_template_row()
|
|
result = _row_to_dict(row)
|
|
assert isinstance(result["placeholders"], list)
|
|
assert "{{COMPANY_NAME}}" in result["placeholders"]
|
|
|
|
def test_get_tenant_id_default(self):
|
|
"""_get_tenant_id returns default when no header provided."""
|
|
result = _get_tenant_id(None)
|
|
assert result == DEFAULT_TENANT_ID
|
|
|
|
def test_get_tenant_id_valid_uuid(self):
|
|
"""_get_tenant_id returns provided UUID when valid."""
|
|
custom_uuid = "12345678-1234-1234-1234-123456789abc"
|
|
result = _get_tenant_id(custom_uuid)
|
|
assert result == custom_uuid
|
|
|
|
def test_get_tenant_id_invalid_uuid(self):
|
|
"""_get_tenant_id falls back to default for invalid UUID."""
|
|
result = _get_tenant_id("not-a-uuid")
|
|
assert result == DEFAULT_TENANT_ID
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSearch
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSearch:
|
|
def setup_method(self):
|
|
self.db = make_db_mock()
|
|
app.dependency_overrides[get_db] = lambda: self.db
|
|
self.client = TestClient(app)
|
|
|
|
def teardown_method(self):
|
|
app.dependency_overrides.clear()
|
|
|
|
def _setup_list(self, rows, total=None):
|
|
count_row = MagicMock()
|
|
count_row.__getitem__ = lambda self, i: total if total is not None else len(rows)
|
|
self.db.execute.return_value.fetchone.side_effect = [count_row]
|
|
self.db.execute.return_value.fetchall.return_value = rows
|
|
|
|
def test_list_returns_200(self):
|
|
"""GET /legal-templates returns 200 with templates list."""
|
|
rows = [make_template_row()]
|
|
count_mock = MagicMock()
|
|
count_mock.__getitem__ = lambda self, i: 1
|
|
|
|
execute_results = []
|
|
first_call = MagicMock()
|
|
first_call.fetchone.return_value = count_mock
|
|
second_call = MagicMock()
|
|
second_call.fetchall.return_value = rows
|
|
self.db.execute.side_effect = [first_call, second_call]
|
|
|
|
res = self.client.get("/legal-templates")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "templates" in data
|
|
assert "total" in data
|
|
|
|
def test_list_filter_by_document_type(self):
|
|
"""GET /legal-templates?document_type=impressum passes filter."""
|
|
count_mock = MagicMock()
|
|
count_mock.__getitem__ = lambda self, i: 0
|
|
first_call = MagicMock()
|
|
first_call.fetchone.return_value = count_mock
|
|
second_call = MagicMock()
|
|
second_call.fetchall.return_value = []
|
|
self.db.execute.side_effect = [first_call, second_call]
|
|
|
|
res = self.client.get("/legal-templates?document_type=impressum")
|
|
assert res.status_code == 200
|
|
# Verify the SQL call used document_type filter
|
|
call_args = self.db.execute.call_args_list[0]
|
|
sql_str = str(call_args[0][0])
|
|
assert "document_type" in sql_str
|
|
|
|
def test_create_invalid_document_type_returns_400(self):
|
|
"""POST /legal-templates with invalid document_type returns 400."""
|
|
res = self.client.post("/legal-templates", json={
|
|
"document_type": "unknown_type",
|
|
"title": "Test",
|
|
"content": "# Test"
|
|
})
|
|
assert res.status_code == 400
|
|
assert "document_type" in res.json()["detail"]
|
|
|
|
def test_create_invalid_status_returns_400(self):
|
|
"""POST /legal-templates with invalid status returns 400."""
|
|
res = self.client.post("/legal-templates", json={
|
|
"document_type": "privacy_policy",
|
|
"title": "Test",
|
|
"content": "# Test",
|
|
"status": "invalid_status"
|
|
})
|
|
assert res.status_code == 400
|
|
|
|
def test_get_nonexistent_returns_404(self):
|
|
"""GET /legal-templates/{id} for unknown ID returns 404."""
|
|
self.db.execute.return_value.fetchone.return_value = None
|
|
res = self.client.get(f"/legal-templates/{UNKNOWN_ID}")
|
|
assert res.status_code == 404
|
|
|
|
def test_update_no_fields_returns_400(self):
|
|
"""PUT /legal-templates/{id} with empty body returns 400."""
|
|
res = self.client.put(f"/legal-templates/{TEMPLATE_ID}", json={})
|
|
assert res.status_code == 400
|
|
assert "No fields" in res.json()["detail"]
|
|
|
|
def test_delete_nonexistent_returns_404(self):
|
|
"""DELETE /legal-templates/{id} for unknown ID returns 404."""
|
|
result_mock = MagicMock()
|
|
result_mock.rowcount = 0
|
|
self.db.execute.return_value = result_mock
|
|
res = self.client.delete(f"/legal-templates/{UNKNOWN_ID}")
|
|
assert res.status_code == 404
|
|
|
|
def test_status_endpoint_returns_200(self):
|
|
"""GET /legal-templates/status returns count structure."""
|
|
row = MagicMock()
|
|
row._mapping = {
|
|
"total": 3, "published": 3, "draft": 0, "archived": 0,
|
|
"privacy_policy": 1, "terms_of_service": 1, "impressum": 1
|
|
}
|
|
self.db.execute.return_value.fetchone.return_value = row
|
|
res = self.client.get("/legal-templates/status")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert data["total"] == 3
|
|
assert "by_type" in data
|
|
assert "by_status" in data
|
|
|
|
def test_sources_endpoint_returns_list(self):
|
|
"""GET /legal-templates/sources returns sources list."""
|
|
self.db.execute.return_value.fetchall.return_value = [
|
|
("BreakPilot Compliance",),
|
|
]
|
|
res = self.client.get("/legal-templates/sources")
|
|
assert res.status_code == 200
|
|
data = res.json()
|
|
assert "sources" in data
|
|
assert "BreakPilot Compliance" in data["sources"]
|
|
|
|
|
|
# =============================================================================
|
|
# TestLegalTemplateSeed
|
|
# =============================================================================
|
|
|
|
class TestLegalTemplateSeed:
|
|
"""Validate that seed template structures are correct."""
|
|
|
|
def test_dse_placeholders_present(self):
|
|
"""DSE template row has expected placeholder tokens."""
|
|
row = make_template_row({
|
|
"document_type": "privacy_policy",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_ADDRESS}}", "{{COMPANY_CITY}}",
|
|
"{{CONTACT_EMAIL}}", "{{DPO_NAME}}", "{{DPO_EMAIL}}",
|
|
"{{SUPERVISORY_AUTHORITY}}", "{{VERSION_DATE}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{COMPANY_NAME}}" in result["placeholders"]
|
|
assert "{{CONTACT_EMAIL}}" in result["placeholders"]
|
|
assert "{{VERSION_DATE}}" in result["placeholders"]
|
|
assert len(result["placeholders"]) == 8
|
|
|
|
def test_impressum_has_ceo_placeholder(self):
|
|
"""Impressum template row has CEO_NAME placeholder."""
|
|
row = make_template_row({
|
|
"document_type": "impressum",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{COMPANY_LEGAL_FORM}}", "{{COMPANY_ADDRESS}}",
|
|
"{{COMPANY_CITY}}", "{{CEO_NAME}}", "{{COMPANY_PHONE}}",
|
|
"{{CONTACT_EMAIL}}", "{{WEBSITE_URL}}", "{{REGISTER_SECTION}}", "{{VAT_SECTION}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{CEO_NAME}}" in result["placeholders"]
|
|
assert "{{WEBSITE_URL}}" in result["placeholders"]
|
|
|
|
def test_agb_has_pricing_placeholder(self):
|
|
"""AGB template row has PRICING_SECTION placeholder."""
|
|
row = make_template_row({
|
|
"document_type": "terms_of_service",
|
|
"placeholders": [
|
|
"{{COMPANY_NAME}}", "{{SERVICE_DESCRIPTION}}", "{{PRICING_SECTION}}",
|
|
"{{PAYMENT_TERMS}}", "{{COMPANY_CITY}}", "{{PRIVACY_POLICY_URL}}", "{{VERSION_DATE}}"
|
|
]
|
|
})
|
|
result = _row_to_dict(row)
|
|
assert "{{PRICING_SECTION}}" in result["placeholders"]
|
|
assert "{{PAYMENT_TERMS}}" in result["placeholders"]
|
|
assert "{{PRIVACY_POLICY_URL}}" in result["placeholders"]
|
|
|
|
def test_all_seeds_use_mit_license(self):
|
|
"""All seed templates declare MIT license and no attribution required."""
|
|
for doc_type in ["privacy_policy", "terms_of_service", "impressum"]:
|
|
row = make_template_row({"document_type": doc_type})
|
|
result = _row_to_dict(row)
|
|
assert result["license_id"] == "mit"
|
|
assert result["license_name"] == "MIT License"
|
|
assert result["attribution_required"] is False
|
|
assert result["is_complete_document"] is True
|