"""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), # Attribution columns (Migration 019) "source_url": None, "source_repo": None, "source_file_path": None, "source_retrieved_at": None, "attribution_text": "Self-authored by BreakPilot Compliance (MIT License, 2026-03-04).", "inspiration_sources": [{"source": "Self-authored", "license": "MIT"}], } 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 all 6 expected types.""" assert "privacy_policy" in VALID_DOCUMENT_TYPES assert "terms_of_service" in VALID_DOCUMENT_TYPES assert "impressum" in VALID_DOCUMENT_TYPES assert "data_processing_agreement" in VALID_DOCUMENT_TYPES assert "withdrawal_policy" in VALID_DOCUMENT_TYPES assert "cookie_policy" in VALID_DOCUMENT_TYPES assert len(VALID_DOCUMENT_TYPES) == 6 def test_create_schema_attribution_fields(self): """LegalTemplateCreate accepts attribution fields.""" payload = LegalTemplateCreate( document_type="data_processing_agreement", title="AVV Test", content="# AVV\n{{CONTROLLER_NAME}}", source_url="https://github.com/example/legal", source_repo="https://github.com/example/legal", source_file_path="dpa-de.md", attribution_text="Self-authored MIT.", inspiration_sources=[{"source": "test", "license": "MIT"}], ) assert payload.source_url == "https://github.com/example/legal" assert payload.attribution_text == "Self-authored MIT." assert len(payload.inspiration_sources) == 1 def test_update_schema_inspiration_sources(self): """LegalTemplateUpdate accepts inspiration_sources list.""" payload = LegalTemplateUpdate( inspiration_sources=[{"source": "Self-authored", "license": "MIT"}] ) d = payload.model_dump(exclude_unset=True) assert "inspiration_sources" in d assert d["inspiration_sources"][0]["license"] == "MIT" 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 with all 6 types.""" row = MagicMock() row._mapping = { "total": 9, "published": 9, "draft": 0, "archived": 0, "privacy_policy": 2, "terms_of_service": 2, "impressum": 1, "data_processing_agreement": 2, "withdrawal_policy": 1, "cookie_policy": 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"] == 9 assert "by_type" in data assert "by_status" in data assert "data_processing_agreement" in data["by_type"] assert "withdrawal_policy" in data["by_type"] assert "cookie_policy" in data["by_type"] 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 def test_dpa_template_has_controller_processor_placeholders(self): """AVV/DPA template has CONTROLLER_NAME and PROCESSOR_NAME placeholders.""" row = make_template_row({ "document_type": "data_processing_agreement", "placeholders": [ "{{CONTROLLER_NAME}}", "{{CONTROLLER_ADDRESS}}", "{{CONTROLLER_CITY}}", "{{PROCESSOR_NAME}}", "{{PROCESSOR_ADDRESS}}", "{{PROCESSOR_CITY}}", "{{SERVICE_DESCRIPTION}}", "{{DATA_CATEGORIES}}", "{{DATA_SUBJECTS}}", "{{CONTRACT_DATE}}", "{{SUPERVISORY_AUTHORITY}}" ] }) result = _row_to_dict(row) assert "{{CONTROLLER_NAME}}" in result["placeholders"] assert "{{PROCESSOR_NAME}}" in result["placeholders"] assert "{{DATA_CATEGORIES}}" in result["placeholders"] def test_withdrawal_policy_has_public_domain_license(self): """Widerrufsbelehrung uses Public Domain license (based on §5 UrhG source).""" row = make_template_row({ "document_type": "withdrawal_policy", "license_id": "public_domain", "license_name": "Public Domain / Gemeinfrei", "source_url": "https://www.gesetze-im-internet.de/egbgb/anlage_1.html", "attribution_text": "Basiert auf der amtlichen Musterwiderrufsbelehrung.", "inspiration_sources": [{"source": "EGBGB Anlage 1", "license": "Public Domain gemäß §5 UrhG"}], }) result = _row_to_dict(row) assert result["document_type"] == "withdrawal_policy" assert result["license_id"] == "public_domain" assert result["source_url"] == "https://www.gesetze-im-internet.de/egbgb/anlage_1.html" assert result["attribution_text"] is not None def test_attribution_text_stored_in_row(self): """attribution_text and inspiration_sources are returned in _row_to_dict.""" row = make_template_row({ "attribution_text": "Self-authored by BreakPilot Compliance (MIT License, 2026-03-04).", "inspiration_sources": [{"source": "Self-authored", "license": "MIT"}], "source_url": None, "source_repo": None, "source_file_path": None, "source_retrieved_at": None, }) result = _row_to_dict(row) assert "Self-authored" in result["attribution_text"] assert result["inspiration_sources"][0]["license"] == "MIT" def test_english_privacy_policy_eu_jurisdiction(self): """EN Privacy Policy template uses EU jurisdiction.""" row = make_template_row({ "document_type": "privacy_policy", "language": "en", "jurisdiction": "EU", "title": "Privacy Policy (GDPR-compliant, EU)", }) result = _row_to_dict(row) assert result["language"] == "en" assert result["jurisdiction"] == "EU" def test_cookie_policy_type_valid(self): """cookie_policy is accepted as valid document_type.""" assert "cookie_policy" in VALID_DOCUMENT_TYPES def test_withdrawal_policy_type_valid(self): """withdrawal_policy is accepted as valid document_type.""" assert "withdrawal_policy" in VALID_DOCUMENT_TYPES def test_dpa_type_valid(self): """data_processing_agreement is accepted as valid document_type.""" assert "data_processing_agreement" in VALID_DOCUMENT_TYPES