""" Tests for Einwilligungen Routes — 008_einwilligungen migration. Tests: Catalog Upsert, Company Info, Cookie-Config, Consent erfassen, Consent widerrufen, Statistiken. """ 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_catalog(tenant_id='test-tenant'): rec = MagicMock() rec.id = uuid.uuid4() rec.tenant_id = tenant_id rec.selected_data_point_ids = ['dp-001', 'dp-002'] rec.custom_data_points = [] rec.updated_at = datetime.now(timezone.utc) return rec def make_company(tenant_id='test-tenant'): rec = MagicMock() rec.id = uuid.uuid4() rec.tenant_id = tenant_id rec.data = {'company_name': 'Test GmbH', 'email': 'datenschutz@test.de'} rec.updated_at = datetime.now(timezone.utc) return rec def make_cookies(tenant_id='test-tenant'): rec = MagicMock() rec.id = uuid.uuid4() rec.tenant_id = tenant_id rec.categories = [ {'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True}, {'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': False}, ] rec.config = {'position': 'bottom', 'style': 'bar'} rec.updated_at = datetime.now(timezone.utc) return rec def make_consent(tenant_id='test-tenant', user_id='user-001', data_point_id='dp-001', granted=True): rec = MagicMock() rec.id = uuid.uuid4() rec.tenant_id = tenant_id rec.user_id = user_id rec.data_point_id = data_point_id rec.granted = granted rec.granted_at = datetime.now(timezone.utc) rec.revoked_at = None rec.consent_version = '1.0' rec.source = 'website' rec.ip_address = None rec.user_agent = None rec.created_at = datetime.now(timezone.utc) return rec # ============================================================================ # Pydantic Schema Tests # ============================================================================ class TestCatalogUpsert: def test_catalog_upsert_defaults(self): from compliance.api.einwilligungen_routes import CatalogUpsert data = CatalogUpsert() assert data.selected_data_point_ids == [] assert data.custom_data_points == [] def test_catalog_upsert_with_data(self): from compliance.api.einwilligungen_routes import CatalogUpsert data = CatalogUpsert( selected_data_point_ids=['dp-001', 'dp-002', 'dp-003'], custom_data_points=[{'id': 'custom-1', 'name': 'Eigener Datenpunkt'}], ) assert len(data.selected_data_point_ids) == 3 assert len(data.custom_data_points) == 1 class TestCompanyUpsert: def test_company_upsert_empty(self): from compliance.api.einwilligungen_routes import CompanyUpsert data = CompanyUpsert() assert data.data == {} def test_company_upsert_with_data(self): from compliance.api.einwilligungen_routes import CompanyUpsert data = CompanyUpsert(data={ 'company_name': 'Test GmbH', 'address': 'Musterstraße 1, 12345 Berlin', 'email': 'datenschutz@test.de', 'dpo_name': 'Max Mustermann', }) assert data.data['company_name'] == 'Test GmbH' assert data.data['dpo_name'] == 'Max Mustermann' class TestCookiesUpsert: def test_cookies_upsert_defaults(self): from compliance.api.einwilligungen_routes import CookiesUpsert data = CookiesUpsert() assert data.categories == [] assert data.config == {} def test_cookies_upsert_with_categories(self): from compliance.api.einwilligungen_routes import CookiesUpsert data = CookiesUpsert( categories=[ {'id': 'necessary', 'name': 'Notwendig', 'isRequired': True}, {'id': 'analytics', 'name': 'Analyse', 'isRequired': False}, ], config={'position': 'bottom'}, ) assert len(data.categories) == 2 assert data.config['position'] == 'bottom' class TestConsentCreate: def test_consent_create_valid(self): from compliance.api.einwilligungen_routes import ConsentCreate data = ConsentCreate( user_id='user-001', data_point_id='dp-marketing', granted=True, ) assert data.user_id == 'user-001' assert data.granted is True assert data.consent_version == '1.0' assert data.source is None def test_consent_create_revoke(self): from compliance.api.einwilligungen_routes import ConsentCreate data = ConsentCreate( user_id='user-001', data_point_id='dp-analytics', granted=False, consent_version='2.0', source='cookie-banner', ) assert data.granted is False assert data.consent_version == '2.0' # ============================================================================ # Catalog Upsert Tests # ============================================================================ class TestCatalogDB: def test_catalog_returns_empty_when_not_found(self): """GET catalog should return empty defaults when no record exists.""" from compliance.db.einwilligungen_models import EinwilligungenCatalogDB mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None result = mock_db.query(EinwilligungenCatalogDB).filter().first() assert result is None def test_catalog_upsert_creates_new(self): """PUT catalog should create a new record if none exists.""" from compliance.db.einwilligungen_models import EinwilligungenCatalogDB mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None new_record = EinwilligungenCatalogDB( tenant_id='test-tenant', selected_data_point_ids=['dp-001'], custom_data_points=[], ) assert new_record.tenant_id == 'test-tenant' assert new_record.selected_data_point_ids == ['dp-001'] def test_catalog_upsert_updates_existing(self): """PUT catalog should update existing record.""" existing = make_catalog() existing.selected_data_point_ids = ['dp-001', 'dp-002', 'dp-003'] existing.custom_data_points = [{'id': 'custom-1'}] assert len(existing.selected_data_point_ids) == 3 assert len(existing.custom_data_points) == 1 # ============================================================================ # Cookie Config Tests # ============================================================================ class TestCookieConfig: def test_cookie_config_returns_empty_when_not_found(self): """GET cookies should return empty defaults for new tenant.""" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.first.return_value = None result = mock_db.query().filter().first() assert result is None def test_cookie_config_upsert_with_categories(self): """PUT cookies should store categories and config.""" from compliance.db.einwilligungen_models import EinwilligungenCookiesDB categories = [ {'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True}, ] config = {'position': 'bottom', 'primaryColor': '#6366f1'} rec = EinwilligungenCookiesDB( tenant_id='test-tenant', categories=categories, config=config, ) assert rec.categories[0]['id'] == 'necessary' assert rec.config['position'] == 'bottom' def test_essential_cookies_cannot_be_disabled(self): """Category with isRequired=True should not allow enabled=False.""" categories = [ {'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True}, {'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': True}, ] # Simulate the toggle logic category_id = 'necessary' enabled = False updated = [] for cat in categories: if cat['id'] == category_id: if cat.get('isRequired') and not enabled: updated.append(cat) # Not changed else: updated.append({**cat, 'defaultEnabled': enabled}) else: updated.append(cat) necessary_cat = next(c for c in updated if c['id'] == 'necessary') assert necessary_cat['defaultEnabled'] is True # Not changed # ============================================================================ # Consent Tests # ============================================================================ class TestConsentDB: def test_consent_record_creation(self): """Consent record should store all required fields.""" from compliance.db.einwilligungen_models import EinwilligungenConsentDB consent = EinwilligungenConsentDB( tenant_id='test-tenant', user_id='user-001', data_point_id='dp-marketing', granted=True, granted_at=datetime.now(timezone.utc), consent_version='1.0', source='website', ) assert consent.tenant_id == 'test-tenant' assert consent.granted is True assert consent.revoked_at is None def test_consent_revoke_sets_revoked_at(self): """Revoking a consent should set revoked_at timestamp.""" consent = make_consent() assert consent.revoked_at is None consent.revoked_at = datetime.now(timezone.utc) assert consent.revoked_at is not None def test_cannot_revoke_already_revoked(self): """Should not be possible to revoke an already revoked consent.""" consent = make_consent() consent.revoked_at = datetime.now(timezone.utc) # Simulate the guard logic from the route already_revoked = consent.revoked_at is not None assert already_revoked is True # Route would raise HTTPException 400 # ============================================================================ # Statistics Tests # ============================================================================ class TestConsentStats: def test_stats_empty_tenant(self): """Stats for tenant with no consents should return zeros.""" consents = [] total = len(consents) active = sum(1 for c in consents if c.granted and not c.revoked_at) revoked = sum(1 for c in consents if c.revoked_at) unique_users = len(set(c.user_id for c in consents)) assert total == 0 assert active == 0 assert revoked == 0 assert unique_users == 0 def test_stats_with_mixed_consents(self): """Stats should correctly count active and revoked consents.""" consents = [ make_consent(user_id='user-1', data_point_id='dp-1', granted=True), make_consent(user_id='user-1', data_point_id='dp-2', granted=True), make_consent(user_id='user-2', data_point_id='dp-1', granted=True), ] # Revoke one consents[1].revoked_at = datetime.now(timezone.utc) total = len(consents) active = sum(1 for c in consents if c.granted and not c.revoked_at) revoked = sum(1 for c in consents if c.revoked_at) unique_users = len(set(c.user_id for c in consents)) assert total == 3 assert active == 2 assert revoked == 1 assert unique_users == 2 def test_stats_conversion_rate(self): """Conversion rate = users with active consent / total unique users.""" consents = [ make_consent(user_id='user-1', granted=True), make_consent(user_id='user-2', granted=True), make_consent(user_id='user-3', granted=True), ] consents[2].revoked_at = datetime.now(timezone.utc) # user-3 revoked unique_users = len(set(c.user_id for c in consents)) users_with_active = len(set(c.user_id for c in consents if c.granted and not c.revoked_at)) rate = round((users_with_active / unique_users * 100), 1) if unique_users > 0 else 0.0 assert unique_users == 3 assert users_with_active == 2 assert rate == pytest.approx(66.7, 0.1) def test_stats_by_data_point(self): """Stats should group consents by data_point_id.""" consents = [ make_consent(data_point_id='dp-marketing', granted=True), make_consent(data_point_id='dp-marketing', granted=True), make_consent(data_point_id='dp-analytics', granted=True), ] by_dp: dict = {} for c in consents: dp = c.data_point_id if dp not in by_dp: by_dp[dp] = {'total': 0, 'active': 0} by_dp[dp]['total'] += 1 if c.granted and not c.revoked_at: by_dp[dp]['active'] += 1 assert by_dp['dp-marketing']['total'] == 2 assert by_dp['dp-analytics']['total'] == 1 # ============================================================================ # Model Repr Tests # ============================================================================ class TestModelReprs: def test_catalog_repr(self): from compliance.db.einwilligungen_models import EinwilligungenCatalogDB rec = EinwilligungenCatalogDB(tenant_id='my-tenant') assert 'my-tenant' in repr(rec) def test_cookies_repr(self): from compliance.db.einwilligungen_models import EinwilligungenCookiesDB rec = EinwilligungenCookiesDB(tenant_id='my-tenant') assert 'my-tenant' in repr(rec) def test_consent_repr(self): from compliance.db.einwilligungen_models import EinwilligungenConsentDB rec = EinwilligungenConsentDB( tenant_id='t1', user_id='u1', data_point_id='dp1', granted=True ) assert 'u1' in repr(rec) assert 'dp1' in repr(rec) # ============================================================================ # Consent Response Field Tests (Regression: Phase 3 — ip_address + user_agent) # ============================================================================ class TestConsentResponseFields: def test_consent_response_includes_ip_address(self): """GET /consents Serialisierung muss ip_address enthalten.""" c = make_consent() c.ip_address = '10.0.0.1' row = { "id": str(c.id), "tenant_id": c.tenant_id, "user_id": c.user_id, "data_point_id": c.data_point_id, "granted": c.granted, "granted_at": c.granted_at, "revoked_at": c.revoked_at, "consent_version": c.consent_version, "source": c.source, "ip_address": c.ip_address, "user_agent": c.user_agent, "created_at": c.created_at, } assert "ip_address" in row assert row["ip_address"] == '10.0.0.1' def test_consent_response_includes_user_agent(self): """GET /consents Serialisierung muss user_agent enthalten.""" c = make_consent() c.user_agent = 'Mozilla/5.0 (Test)' row = { "id": str(c.id), "tenant_id": c.tenant_id, "user_id": c.user_id, "data_point_id": c.data_point_id, "granted": c.granted, "granted_at": c.granted_at, "revoked_at": c.revoked_at, "consent_version": c.consent_version, "source": c.source, "ip_address": c.ip_address, "user_agent": c.user_agent, "created_at": c.created_at, } assert "user_agent" in row assert row["user_agent"] == 'Mozilla/5.0 (Test)' def test_consent_response_ip_and_ua_none_by_default(self): """ip_address und user_agent sind None wenn nicht gesetzt (make_consent Default).""" c = make_consent() row = {"ip_address": c.ip_address, "user_agent": c.user_agent} assert row["ip_address"] is None assert row["user_agent"] is None # ============================================================================ # History-Tracking Tests (Migration 009) # ============================================================================ class TestConsentHistoryTracking: def test_record_history_helper_builds_entry(self): """_record_history() erstellt korrekt befuelltes EinwilligungenConsentHistoryDB-Objekt.""" from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB consent = make_consent() consent.ip_address = '10.0.0.1' consent.user_agent = 'TestAgent/1.0' consent.source = 'test-source' mock_db = MagicMock() # Simulate _record_history inline (mirrors implementation) entry = EinwilligungenConsentHistoryDB( consent_id=consent.id, tenant_id=consent.tenant_id, action='granted', consent_version=consent.consent_version, ip_address=consent.ip_address, user_agent=consent.user_agent, source=consent.source, ) mock_db.add(entry) assert entry.tenant_id == consent.tenant_id assert entry.consent_id == consent.id assert entry.ip_address == '10.0.0.1' assert entry.user_agent == 'TestAgent/1.0' assert entry.source == 'test-source' mock_db.add.assert_called_once_with(entry) def test_history_entry_has_correct_action_granted(self): """History-Eintrag bei Einwilligung hat action='granted'.""" from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB consent = make_consent() entry = EinwilligungenConsentHistoryDB( consent_id=consent.id, tenant_id=consent.tenant_id, action='granted', consent_version=consent.consent_version, ) assert entry.action == 'granted' def test_history_entry_has_correct_action_revoked(self): """History-Eintrag bei Widerruf hat action='revoked'.""" from compliance.db.einwilligungen_models import EinwilligungenConsentHistoryDB consent = make_consent() consent.revoked_at = datetime.now(timezone.utc) entry = EinwilligungenConsentHistoryDB( consent_id=consent.id, tenant_id=consent.tenant_id, action='revoked', consent_version=consent.consent_version, ) assert entry.action == 'revoked' def test_history_serialization_format(self): """Response-Dict fuer einen History-Eintrag enthaelt alle 8 Pflichtfelder.""" import uuid as _uuid entry_id = _uuid.uuid4() consent_id = _uuid.uuid4() now = datetime.now(timezone.utc) row = { "id": str(entry_id), "consent_id": str(consent_id), "action": "granted", "consent_version": "1.0", "ip_address": "192.168.1.1", "user_agent": "Mozilla/5.0", "source": "web_banner", "created_at": now, } assert len(row) == 8 assert "id" in row assert "consent_id" in row assert "action" in row assert "consent_version" in row assert "ip_address" in row assert "user_agent" in row assert "source" in row assert "created_at" in row def test_history_empty_list_for_no_entries(self): """GET /consents/{id}/history gibt leere Liste zurueck wenn keine Eintraege vorhanden.""" mock_db = MagicMock() mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [] entries = mock_db.query().filter().order_by().all() result = [{"id": str(e.id), "action": e.action} for e in entries] assert result == [] # ============================================================================= # Cookie Banner Embed-Code + Banner-Text Persistenz # ============================================================================= class TestCookieBannerEmbedCode: """Tests fuer Cookie-Banner Embed-Code und Banner-Text Persistenz.""" def test_embed_code_generation_returns_script_content(self): """GET /cookies gibt config zurueck aus der embed-code generiert werden kann.""" from compliance.db.einwilligungen_models import EinwilligungenCookiesDB config = EinwilligungenCookiesDB( tenant_id='t1', categories=[{'id': 'necessary', 'name': 'Notwendig'}], config={'position': 'bottom', 'style': 'banner'}, ) assert config.tenant_id == 't1' assert len(config.categories) == 1 assert config.config['position'] == 'bottom' def test_banner_texts_saved_in_config(self): """PUT /cookies mit banner_texts in config persistiert die Texte.""" from compliance.db.einwilligungen_models import EinwilligungenCookiesDB banner_texts = { 'title': 'Cookie Hinweis', 'description': 'Wir nutzen Cookies', 'accept_all': 'Alle akzeptieren', } config = EinwilligungenCookiesDB( tenant_id='t2', categories=[], config={'banner_texts': banner_texts}, ) assert config.config['banner_texts']['title'] == 'Cookie Hinweis' assert config.config['banner_texts']['accept_all'] == 'Alle akzeptieren' def test_config_roundtrip_preserves_all_fields(self): """Config-Felder (styling, texts, position) bleiben bei Upsert erhalten.""" from compliance.db.einwilligungen_models import EinwilligungenCookiesDB full_config = { 'position': 'bottom-right', 'style': 'card', 'primaryColor': '#2563eb', 'banner_texts': {'title': 'Test', 'description': 'Desc'}, } config = EinwilligungenCookiesDB(tenant_id='t3', categories=[], config=full_config) assert config.config['primaryColor'] == '#2563eb' assert config.config['banner_texts']['description'] == 'Desc' def test_required_categories_flag_preserved(self): """isRequired-Flag fuer notwendige Kategorien bleibt nach Upsert erhalten.""" from compliance.db.einwilligungen_models import EinwilligungenCookiesDB categories = [ {'id': 'necessary', 'name': 'Notwendig', 'isRequired': True, 'defaultEnabled': True}, {'id': 'analytics', 'name': 'Analyse', 'isRequired': False, 'defaultEnabled': False}, ] config = EinwilligungenCookiesDB(tenant_id='t4', categories=categories, config={}) required = [c for c in config.categories if c.get('isRequired')] assert len(required) == 1 assert required[0]['id'] == 'necessary'