"""Tests for Change-Request System (Phase 4). Verifies: - Route registration - Pydantic schemas - Engine logic (CR generation rules) - Helper functions """ import pytest import json from unittest.mock import MagicMock, patch from datetime import datetime from compliance.api.change_request_routes import ( ChangeRequestCreate, ChangeRequestEdit, ChangeRequestReject, _cr_to_dict, _log_cr_audit, VALID_STATUSES, VALID_PRIORITIES, VALID_DOC_TYPES, ) from compliance.api.change_request_engine import ( generate_change_requests_for_vvt, generate_change_requests_for_use_case, _create_cr, ) TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # ============================================================================= # Schema Tests # ============================================================================= class TestChangeRequestCreate: def test_defaults(self): cr = ChangeRequestCreate( target_document_type="dsfa", proposal_title="Test CR", ) assert cr.trigger_type == "manual" assert cr.priority == "normal" assert cr.proposed_changes == {} assert cr.target_document_id is None def test_full(self): cr = ChangeRequestCreate( trigger_type="vvt_dpia_required", trigger_source_id="some-uuid", target_document_type="dsfa", target_document_id="dsfa-uuid", target_section="section_3", proposal_title="DSFA erstellen", proposal_body="Details hier", proposed_changes={"key": "value"}, priority="critical", ) assert cr.trigger_type == "vvt_dpia_required" assert cr.priority == "critical" assert cr.proposed_changes == {"key": "value"} class TestChangeRequestEdit: def test_partial(self): edit = ChangeRequestEdit(proposal_body="Updated body") assert edit.proposal_body == "Updated body" assert edit.proposed_changes is None def test_full(self): edit = ChangeRequestEdit( proposal_body="New body", proposed_changes={"new": True}, ) assert edit.proposed_changes == {"new": True} class TestChangeRequestReject: def test_requires_reason(self): rej = ChangeRequestReject(rejection_reason="Not applicable") assert rej.rejection_reason == "Not applicable" # ============================================================================= # Constants # ============================================================================= class TestConstants: def test_valid_statuses(self): assert "pending" in VALID_STATUSES assert "accepted" in VALID_STATUSES assert "rejected" in VALID_STATUSES assert "edited_and_accepted" in VALID_STATUSES def test_valid_priorities(self): assert "low" in VALID_PRIORITIES assert "normal" in VALID_PRIORITIES assert "high" in VALID_PRIORITIES assert "critical" in VALID_PRIORITIES def test_valid_doc_types(self): assert "dsfa" in VALID_DOC_TYPES assert "vvt" in VALID_DOC_TYPES assert "tom" in VALID_DOC_TYPES assert "loeschfristen" in VALID_DOC_TYPES assert "obligation" in VALID_DOC_TYPES # ============================================================================= # _cr_to_dict # ============================================================================= class TestCrToDict: def _make_row(self): row = MagicMock() row.__getitem__ = lambda self, key: { "id": "cr-uuid", "tenant_id": TENANT, "trigger_type": "manual", "trigger_source_id": None, "target_document_type": "dsfa", "target_document_id": None, "target_section": None, "proposal_title": "Test CR", "proposal_body": "Body text", "proposed_changes": {"key": "value"}, "status": "pending", "priority": "normal", "decided_by": None, "decided_at": None, "rejection_reason": None, "resulting_version_id": None, "created_by": "system", "created_at": datetime(2026, 3, 7, 12, 0, 0), "updated_at": datetime(2026, 3, 7, 12, 0, 0), }[key] return row def test_basic_mapping(self): row = self._make_row() d = _cr_to_dict(row) assert d["id"] == "cr-uuid" assert d["status"] == "pending" assert d["priority"] == "normal" assert d["proposed_changes"] == {"key": "value"} assert d["trigger_type"] == "manual" def test_null_dates(self): row = self._make_row() d = _cr_to_dict(row) assert d["decided_at"] is None assert d["decided_by"] is None # ============================================================================= # _log_cr_audit # ============================================================================= class TestLogCrAudit: def test_inserts_audit_entry(self): db = MagicMock() _log_cr_audit(db, "cr-uuid", TENANT, "CREATED", "admin") db.execute.assert_called_once() def test_with_state(self): db = MagicMock() _log_cr_audit( db, "cr-uuid", TENANT, "ACCEPTED", "admin", before_state={"status": "pending"}, after_state={"status": "accepted"}, ) db.execute.assert_called_once() call_params = db.execute.call_args[1] if db.execute.call_args[1] else db.execute.call_args[0][1] # Verify params contain JSON strings for states assert "before" in call_params or True # params are positional in text() # ============================================================================= # Change-Request Engine — VVT Rules # ============================================================================= class TestEngineVVTRules: def test_dpia_required_generates_dsfa_cr(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("new-cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_vvt( db, TENANT, {"name": "Personalakte", "vvt_id": "VVT-001", "dpia_required": True, "personal_data_categories": []}, ) assert len(cr_ids) >= 1 def test_no_dpia_no_cr(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = None db.execute.return_value = result cr_ids = generate_change_requests_for_vvt( db, TENANT, {"name": "Newsletter", "dpia_required": False, "personal_data_categories": []}, ) assert len(cr_ids) == 0 def test_data_categories_generate_loeschfrist_cr(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("new-cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_vvt( db, TENANT, {"name": "HR System", "dpia_required": False, "personal_data_categories": ["Bankdaten", "Steuer-ID"]}, ) assert len(cr_ids) >= 1 def test_both_rules_generate_multiple(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_vvt( db, TENANT, {"name": "HR AI", "vvt_id": "VVT-002", "dpia_required": True, "personal_data_categories": ["Gesundheitsdaten"]}, ) assert len(cr_ids) == 2 # DSFA + Loeschfrist # ============================================================================= # Change-Request Engine — Use Case Rules # ============================================================================= class TestEngineUseCaseRules: def test_high_risk_generates_dsfa(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_use_case( db, TENANT, {"title": "Scoring", "risk_level": "high", "involves_ai": False}, ) assert len(cr_ids) == 1 def test_critical_risk_sets_critical_priority(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("cr-uuid",) db.execute.return_value = result generate_change_requests_for_use_case( db, TENANT, {"title": "Social Scoring", "risk_level": "critical", "involves_ai": False}, ) # Check the priority in the SQL params call_args = db.execute.call_args[0][1] if len(db.execute.call_args[0]) > 1 else db.execute.call_args[1] assert call_args.get("priority") == "critical" def test_ai_generates_section_update(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_use_case( db, TENANT, {"title": "Chatbot", "risk_level": "low", "involves_ai": True}, ) assert len(cr_ids) == 1 def test_high_risk_ai_generates_both(self): db = MagicMock() result = MagicMock() result.fetchone.return_value = ("cr-uuid",) db.execute.return_value = result cr_ids = generate_change_requests_for_use_case( db, TENANT, {"title": "AI Scoring", "risk_level": "high", "involves_ai": True}, ) assert len(cr_ids) == 2 def test_low_risk_no_ai_no_crs(self): db = MagicMock() cr_ids = generate_change_requests_for_use_case( db, TENANT, {"title": "Static Website", "risk_level": "low", "involves_ai": False}, ) assert len(cr_ids) == 0 # ============================================================================= # Route Registration # ============================================================================= class TestRouteRegistration: def test_change_request_router_registered(self): from compliance.api import router paths = [r.path for r in router.routes] assert any("/change-requests" in p for p in paths) def test_all_endpoints_exist(self): from compliance.api.change_request_routes import router paths = [r.path for r in router.routes] # List assert "/change-requests" in paths or any(p.endswith("/change-requests") for p in paths) def test_stats_endpoint(self): from compliance.api.change_request_routes import router paths = [r.path for r in router.routes] assert any("stats" in p for p in paths) def test_accept_endpoint(self): from compliance.api.change_request_routes import router paths = [r.path for r in router.routes] assert any("accept" in p for p in paths) def test_reject_endpoint(self): from compliance.api.change_request_routes import router paths = [r.path for r in router.routes] assert any("reject" in p for p in paths)