Files
breakpilot-compliance/backend-compliance/tests/test_vvt_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

769 lines
30 KiB
Python

"""Tests for VVT routes and schemas (vvt_routes.py, vvt_models.py)."""
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime, date, timedelta, timezone
import uuid
from compliance.api.schemas import (
VVTActivityCreate,
VVTActivityUpdate,
VVTOrganizationUpdate,
VVTStatsResponse,
)
from compliance.api.vvt_routes import _activity_to_response, _log_audit
from compliance.db.vvt_models import VVTActivityDB, VVTOrganizationDB, VVTAuditLogDB
# =============================================================================
# Schema Tests
# =============================================================================
class TestVVTActivityCreate:
def test_default_values(self):
req = VVTActivityCreate(vvt_id="VVT-001", name="Test Verarbeitung")
assert req.vvt_id == "VVT-001"
assert req.name == "Test Verarbeitung"
assert req.status == "DRAFT"
assert req.protection_level == "MEDIUM"
assert req.dpia_required is False
assert req.purposes == []
assert req.legal_bases == []
def test_full_values(self):
req = VVTActivityCreate(
vvt_id="VVT-002",
name="Gehaltsabrechnung",
description="Verarbeitung von Gehaltsabrechnungsdaten",
purposes=["Vertragserfuellung"],
legal_bases=["Art. 6 Abs. 1b DSGVO"],
data_subject_categories=["Mitarbeiter"],
personal_data_categories=["Bankdaten", "Steuer-ID"],
status="APPROVED",
dpia_required=False,
)
assert req.vvt_id == "VVT-002"
assert req.status == "APPROVED"
assert len(req.purposes) == 1
assert len(req.personal_data_categories) == 2
def test_serialization(self):
req = VVTActivityCreate(vvt_id="VVT-003", name="Test")
data = req.model_dump()
assert data["vvt_id"] == "VVT-003"
assert isinstance(data["purposes"], list)
assert isinstance(data["retention_period"], dict)
class TestVVTActivityUpdate:
def test_partial_update(self):
req = VVTActivityUpdate(status="APPROVED")
data = req.model_dump(exclude_none=True)
assert data == {"status": "APPROVED"}
def test_empty_update(self):
req = VVTActivityUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_multi_field_update(self):
req = VVTActivityUpdate(
name="Updated Name",
dpia_required=True,
protection_level="HIGH",
)
data = req.model_dump(exclude_none=True)
assert data["name"] == "Updated Name"
assert data["dpia_required"] is True
assert data["protection_level"] == "HIGH"
class TestVVTOrganizationUpdate:
def test_defaults(self):
req = VVTOrganizationUpdate()
data = req.model_dump(exclude_none=True)
assert data == {}
def test_partial_update(self):
req = VVTOrganizationUpdate(
organization_name="BreakPilot GmbH",
dpo_name="Max Mustermann",
)
data = req.model_dump(exclude_none=True)
assert data["organization_name"] == "BreakPilot GmbH"
assert data["dpo_name"] == "Max Mustermann"
class TestVVTStatsResponse:
def test_stats_response(self):
stats = VVTStatsResponse(
total=5,
by_status={"DRAFT": 3, "APPROVED": 2},
by_business_function={"HR": 2, "IT": 3},
dpia_required_count=1,
third_country_count=0,
draft_count=3,
approved_count=2,
overdue_review_count=1,
)
assert stats.total == 5
assert stats.by_status["DRAFT"] == 3
assert stats.dpia_required_count == 1
assert stats.overdue_review_count == 1
def test_stats_overdue_default_zero(self):
stats = VVTStatsResponse(
total=0,
by_status={},
by_business_function={},
dpia_required_count=0,
third_country_count=0,
draft_count=0,
approved_count=0,
)
assert stats.overdue_review_count == 0
# =============================================================================
# DB Model Tests
# =============================================================================
class TestVVTModels:
def test_activity_defaults(self):
act = VVTActivityDB()
assert act.status is None or act.status == 'DRAFT'
assert act.dpia_required is False or act.dpia_required is None
def test_activity_repr(self):
act = VVTActivityDB()
act.vvt_id = "VVT-001"
act.name = "Test"
assert "VVT-001" in repr(act)
def test_organization_repr(self):
org = VVTOrganizationDB()
org.organization_name = "Test GmbH"
assert "Test GmbH" in repr(org)
def test_audit_log_repr(self):
log = VVTAuditLogDB()
log.action = "CREATE"
log.entity_type = "activity"
assert "CREATE" in repr(log)
# =============================================================================
# Helper Function Tests
# =============================================================================
class TestActivityToResponse:
def _make_activity(self, **kwargs) -> VVTActivityDB:
act = VVTActivityDB()
act.id = uuid.uuid4()
act.vvt_id = kwargs.get("vvt_id", "VVT-001")
act.name = kwargs.get("name", "Test")
act.description = kwargs.get("description", None)
act.purposes = kwargs.get("purposes", [])
act.legal_bases = kwargs.get("legal_bases", [])
act.data_subject_categories = kwargs.get("data_subject_categories", [])
act.personal_data_categories = kwargs.get("personal_data_categories", [])
act.recipient_categories = kwargs.get("recipient_categories", [])
act.third_country_transfers = kwargs.get("third_country_transfers", [])
act.retention_period = kwargs.get("retention_period", {})
act.tom_description = kwargs.get("tom_description", None)
act.business_function = kwargs.get("business_function", None)
act.systems = kwargs.get("systems", [])
act.deployment_model = kwargs.get("deployment_model", None)
act.data_sources = kwargs.get("data_sources", [])
act.data_flows = kwargs.get("data_flows", [])
act.protection_level = kwargs.get("protection_level", "MEDIUM")
act.dpia_required = kwargs.get("dpia_required", False)
act.structured_toms = kwargs.get("structured_toms", {})
act.status = kwargs.get("status", "DRAFT")
act.responsible = kwargs.get("responsible", None)
act.owner = kwargs.get("owner", None)
act.last_reviewed_at = kwargs.get("last_reviewed_at", None)
act.next_review_at = kwargs.get("next_review_at", None)
act.created_by = kwargs.get("created_by", None)
act.dsfa_id = kwargs.get("dsfa_id", None)
act.created_at = datetime.now(timezone.utc)
act.updated_at = None
return act
def test_basic_conversion(self):
act = self._make_activity(vvt_id="VVT-001", name="Kundendaten")
response = _activity_to_response(act)
assert response.vvt_id == "VVT-001"
assert response.name == "Kundendaten"
assert response.status == "DRAFT"
assert response.protection_level == "MEDIUM"
def test_null_lists_become_empty(self):
act = self._make_activity()
act.purposes = None
act.legal_bases = None
response = _activity_to_response(act)
assert response.purposes == []
assert response.legal_bases == []
def test_null_dicts_become_empty(self):
act = self._make_activity()
act.retention_period = None
act.structured_toms = None
response = _activity_to_response(act)
assert response.retention_period == {}
assert response.structured_toms == {}
class TestLogAudit:
def test_creates_audit_entry(self):
mock_db = MagicMock()
act_id = uuid.uuid4()
_log_audit(
db=mock_db,
tenant_id="9282a473-5c95-4b3a-bf78-0ecc0ec71d3e",
action="CREATE",
entity_type="activity",
entity_id=act_id,
changed_by="test_user",
new_values={"name": "Test"},
)
mock_db.add.assert_called_once()
added = mock_db.add.call_args[0][0]
assert added.action == "CREATE"
assert added.entity_type == "activity"
assert added.entity_id == act_id
def test_defaults_changed_by(self):
mock_db = MagicMock()
_log_audit(mock_db, tenant_id="9282a473-5c95-4b3a-bf78-0ecc0ec71d3e", action="DELETE", entity_type="activity")
added = mock_db.add.call_args[0][0]
assert added.changed_by == "system"
# =============================================================================
# Consolidation Tests (Go → Python feature parity)
# =============================================================================
class TestVVTConsolidationSchemas:
"""Tests for new fields ported from Go: review dates, created_by, dsfa_id."""
def test_activity_create_with_review_dates(self):
now = datetime.now(timezone.utc)
future = now + timedelta(days=365)
req = VVTActivityCreate(
vvt_id="VVT-REV-001",
name="Review-Test",
last_reviewed_at=now,
next_review_at=future,
)
assert req.last_reviewed_at == now
assert req.next_review_at == future
def test_activity_create_sets_created_by(self):
req = VVTActivityCreate(
vvt_id="VVT-CB-001",
name="Created-By Test",
created_by="admin@example.com",
)
assert req.created_by == "admin@example.com"
def test_activity_create_created_by_defaults_none(self):
req = VVTActivityCreate(vvt_id="VVT-CB-002", name="Default Test")
assert req.created_by is None
def test_activity_create_with_dsfa_id(self):
dsfa_uuid = str(uuid.uuid4())
req = VVTActivityCreate(
vvt_id="VVT-DSFA-001",
name="DSFA-Link Test",
dsfa_id=dsfa_uuid,
)
assert req.dsfa_id == dsfa_uuid
def test_activity_update_review_dates(self):
now = datetime.now(timezone.utc)
req = VVTActivityUpdate(
last_reviewed_at=now,
next_review_at=now + timedelta(days=180),
)
data = req.model_dump(exclude_none=True)
assert "last_reviewed_at" in data
assert "next_review_at" in data
def test_activity_update_dsfa_id(self):
dsfa_uuid = str(uuid.uuid4())
req = VVTActivityUpdate(dsfa_id=dsfa_uuid)
data = req.model_dump(exclude_none=True)
assert data["dsfa_id"] == dsfa_uuid
class TestVVTConsolidationResponse:
"""Tests for new fields in response mapping."""
def _make_activity(self, **kwargs) -> VVTActivityDB:
act = VVTActivityDB()
act.id = uuid.uuid4()
act.vvt_id = kwargs.get("vvt_id", "VVT-001")
act.name = kwargs.get("name", "Test")
act.description = None
act.purposes = []
act.legal_bases = []
act.data_subject_categories = []
act.personal_data_categories = []
act.recipient_categories = []
act.third_country_transfers = []
act.retention_period = {}
act.tom_description = None
act.business_function = None
act.systems = []
act.deployment_model = None
act.data_sources = []
act.data_flows = []
act.protection_level = "MEDIUM"
act.dpia_required = False
act.structured_toms = {}
act.status = "DRAFT"
act.responsible = None
act.owner = None
act.last_reviewed_at = kwargs.get("last_reviewed_at", None)
act.next_review_at = kwargs.get("next_review_at", None)
act.created_by = kwargs.get("created_by", None)
act.dsfa_id = kwargs.get("dsfa_id", None)
act.created_at = datetime.now(timezone.utc)
act.updated_at = None
return act
def test_response_includes_review_dates(self):
now = datetime.now(timezone.utc)
future = now + timedelta(days=365)
act = self._make_activity(last_reviewed_at=now, next_review_at=future)
resp = _activity_to_response(act)
assert resp.last_reviewed_at == now
assert resp.next_review_at == future
def test_response_includes_created_by(self):
act = self._make_activity(created_by="admin@example.com")
resp = _activity_to_response(act)
assert resp.created_by == "admin@example.com"
def test_response_includes_dsfa_id(self):
dsfa_uuid = uuid.uuid4()
act = self._make_activity(dsfa_id=dsfa_uuid)
resp = _activity_to_response(act)
assert resp.dsfa_id == str(dsfa_uuid)
def test_response_null_new_fields(self):
act = self._make_activity()
resp = _activity_to_response(act)
assert resp.last_reviewed_at is None
assert resp.next_review_at is None
assert resp.created_by is None
assert resp.dsfa_id is None
class TestVVTCsvExport:
"""Tests for CSV export functionality."""
def _collect_csv_body(self, response) -> str:
"""Extract text from StreamingResponse (async generator)."""
import asyncio
async def _read():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return ''.join(chunks)
return asyncio.get_event_loop().run_until_complete(_read())
def test_export_csv_format(self):
from compliance.api.vvt_routes import _export_csv
act = VVTActivityDB()
act.id = uuid.uuid4()
act.vvt_id = "VVT-CSV-001"
act.name = "CSV Test"
act.purposes = ["Zweck A", "Zweck B"]
act.legal_bases = ["Art. 6 Abs. 1b"]
act.personal_data_categories = ["Email"]
act.data_subject_categories = ["Kunden"]
act.recipient_categories = ["IT-Dienstleister"]
act.third_country_transfers = ["USA"]
act.retention_period = {"duration": "3 Jahre"}
act.status = "APPROVED"
act.responsible = "DSB"
act.created_by = "admin"
act.created_at = datetime(2026, 1, 15, 10, 30)
act.updated_at = None
response = _export_csv([act])
text = self._collect_csv_body(response)
assert 'VVT-CSV-001' in text
assert 'CSV Test' in text
assert 'APPROVED' in text
def test_export_csv_semicolon_separator(self):
from compliance.api.vvt_routes import _export_csv
act = VVTActivityDB()
act.id = uuid.uuid4()
act.vvt_id = "VVT-SEP-001"
act.name = "Separator Test"
act.purposes = []
act.legal_bases = []
act.personal_data_categories = []
act.data_subject_categories = []
act.recipient_categories = []
act.third_country_transfers = []
act.retention_period = {}
act.status = "DRAFT"
act.responsible = ""
act.created_by = "system"
act.created_at = datetime(2026, 3, 1, 12, 0)
act.updated_at = None
response = _export_csv([act])
text = self._collect_csv_body(response)
lines = text.strip().split('\n')
header = lines[0]
assert ';' in header
assert 'ID;VVT-ID;Name' in header.replace('\ufeff', '')
def test_export_csv_empty_list(self):
from compliance.api.vvt_routes import _export_csv
response = _export_csv([])
text = self._collect_csv_body(response)
lines = text.strip().split('\n')
assert len(lines) == 1
# =============================================================================
# API Endpoint Tests (TestClient + mock DB)
# =============================================================================
from fastapi.testclient import TestClient
from fastapi import FastAPI
from compliance.api.vvt_routes import router
_app = FastAPI()
_app.include_router(router)
_client = TestClient(_app)
DEFAULT_TENANT = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
def _make_db_activity(**kwargs):
"""Create a mock VVTActivityDB object for query results."""
act = VVTActivityDB()
act.id = kwargs.get("id", uuid.uuid4())
act.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
act.vvt_id = kwargs.get("vvt_id", "VVT-001")
act.name = kwargs.get("name", "Test Verarbeitung")
act.description = kwargs.get("description", None)
act.purposes = kwargs.get("purposes", ["Vertragserfuellung"])
act.legal_bases = kwargs.get("legal_bases", ["Art. 6 Abs. 1b"])
act.data_subject_categories = kwargs.get("data_subject_categories", ["Kunden"])
act.personal_data_categories = kwargs.get("personal_data_categories", ["Email"])
act.recipient_categories = kwargs.get("recipient_categories", [])
act.third_country_transfers = kwargs.get("third_country_transfers", [])
act.retention_period = kwargs.get("retention_period", {"duration": "3 Jahre"})
act.tom_description = kwargs.get("tom_description", None)
act.business_function = kwargs.get("business_function", "IT")
act.systems = kwargs.get("systems", [])
act.deployment_model = kwargs.get("deployment_model", None)
act.data_sources = kwargs.get("data_sources", [])
act.data_flows = kwargs.get("data_flows", [])
act.protection_level = kwargs.get("protection_level", "MEDIUM")
act.dpia_required = kwargs.get("dpia_required", False)
act.structured_toms = kwargs.get("structured_toms", {})
act.status = kwargs.get("status", "DRAFT")
act.responsible = kwargs.get("responsible", None)
act.owner = kwargs.get("owner", None)
act.last_reviewed_at = kwargs.get("last_reviewed_at", None)
act.next_review_at = kwargs.get("next_review_at", None)
act.created_by = kwargs.get("created_by", "system")
act.dsfa_id = kwargs.get("dsfa_id", None)
act.created_at = kwargs.get("created_at", datetime(2026, 1, 15, 10, 0))
act.updated_at = kwargs.get("updated_at", None)
return act
def _make_db_org(**kwargs):
"""Create a mock VVTOrganizationDB object."""
org = VVTOrganizationDB()
org.id = kwargs.get("id", uuid.uuid4())
org.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
org.organization_name = kwargs.get("organization_name", "BreakPilot GmbH")
org.industry = kwargs.get("industry", "IT")
org.locations = kwargs.get("locations", ["Berlin"])
org.employee_count = kwargs.get("employee_count", 50)
org.dpo_name = kwargs.get("dpo_name", "Max DSB")
org.dpo_contact = kwargs.get("dpo_contact", "dsb@example.com")
org.vvt_version = kwargs.get("vvt_version", "1.0")
org.last_review_date = kwargs.get("last_review_date", None)
org.next_review_date = kwargs.get("next_review_date", None)
org.review_interval = kwargs.get("review_interval", "annual")
org.created_at = kwargs.get("created_at", datetime(2026, 1, 1))
org.updated_at = kwargs.get("updated_at", None)
return org
def _make_audit_entry(**kwargs):
"""Create a mock VVTAuditLogDB object."""
entry = VVTAuditLogDB()
entry.id = kwargs.get("id", uuid.uuid4())
entry.tenant_id = kwargs.get("tenant_id", DEFAULT_TENANT)
entry.action = kwargs.get("action", "CREATE")
entry.entity_type = kwargs.get("entity_type", "activity")
entry.entity_id = kwargs.get("entity_id", uuid.uuid4())
entry.changed_by = kwargs.get("changed_by", "system")
entry.old_values = kwargs.get("old_values", None)
entry.new_values = kwargs.get("new_values", {"name": "Test"})
entry.created_at = kwargs.get("created_at", datetime(2026, 1, 15, 10, 0))
return entry
@pytest.fixture
def mock_db():
from classroom_engine.database import get_db
from compliance.api.tenant_utils import get_tenant_id
db = MagicMock()
_app.dependency_overrides[get_db] = lambda: db
_app.dependency_overrides[get_tenant_id] = lambda: DEFAULT_TENANT
yield db
_app.dependency_overrides.clear()
class TestExportEndpoint:
"""Tests for GET /vvt/export (JSON and CSV)."""
def test_export_json_with_activities(self, mock_db):
act = _make_db_activity(vvt_id="VVT-EXP-001", name="Export Test")
org = _make_db_org()
# mock chained query for org
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = org
# mock chained query for activities
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [act]
resp = _client.get("/vvt/export?format=json")
assert resp.status_code == 200
data = resp.json()
assert "exported_at" in data
assert "organization" in data
assert data["organization"]["name"] == "BreakPilot GmbH"
assert len(data["activities"]) == 1
assert data["activities"][0]["vvt_id"] == "VVT-EXP-001"
def test_export_json_empty_dataset(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = []
resp = _client.get("/vvt/export?format=json")
assert resp.status_code == 200
data = resp.json()
assert data["organization"] is None
assert data["activities"] == []
def test_export_csv_returns_streaming_response(self, mock_db):
act = _make_db_activity(vvt_id="VVT-CSV-E01", name="CSV Endpoint Test")
mock_db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
mock_db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [act]
resp = _client.get("/vvt/export?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.headers.get("content-type", "")
assert "attachment" in resp.headers.get("content-disposition", "")
body = resp.text
assert "VVT-CSV-E01" in body
assert "CSV Endpoint Test" in body
def test_export_invalid_format_rejected(self, mock_db):
resp = _client.get("/vvt/export?format=xml")
assert resp.status_code == 422 # validation error
class TestStatsEndpoint:
"""Tests for GET /vvt/stats."""
def test_stats_empty_tenant(self, mock_db):
mock_db.query.return_value.filter.return_value.all.return_value = []
resp = _client.get("/vvt/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 0
assert data["by_status"] == {}
assert data["dpia_required_count"] == 0
assert data["overdue_review_count"] == 0
def test_stats_with_activities(self, mock_db):
past = datetime(2025, 1, 1, tzinfo=timezone.utc)
acts = [
_make_db_activity(status="DRAFT", business_function="HR", dpia_required=True, next_review_at=past),
_make_db_activity(status="APPROVED", business_function="IT", dpia_required=False),
_make_db_activity(status="DRAFT", business_function="HR", dpia_required=False, third_country_transfers=["USA"]),
]
mock_db.query.return_value.filter.return_value.all.return_value = acts
resp = _client.get("/vvt/stats")
assert resp.status_code == 200
data = resp.json()
assert data["total"] == 3
assert data["by_status"]["DRAFT"] == 2
assert data["by_status"]["APPROVED"] == 1
assert data["by_business_function"]["HR"] == 2
assert data["by_business_function"]["IT"] == 1
assert data["dpia_required_count"] == 1
assert data["third_country_count"] == 1
assert data["draft_count"] == 2
assert data["approved_count"] == 1
assert data["overdue_review_count"] == 1
class TestAuditLogEndpoint:
"""Tests for GET /vvt/audit-log."""
def test_audit_log_returns_entries(self, mock_db):
entry = _make_audit_entry(action="CREATE", entity_type="activity")
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = [entry]
resp = _client.get("/vvt/audit-log")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 1
assert data[0]["action"] == "CREATE"
assert data[0]["entity_type"] == "activity"
def test_audit_log_empty(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
resp = _client.get("/vvt/audit-log")
assert resp.status_code == 200
assert resp.json() == []
def test_audit_log_pagination_params(self, mock_db):
mock_db.query.return_value.filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value = []
resp = _client.get("/vvt/audit-log?limit=10&offset=20")
assert resp.status_code == 200
class TestVersioningEndpoints:
"""Tests for GET /vvt/activities/{id}/versions and /versions/{v}."""
@patch("compliance.api.versioning_utils.list_versions")
def test_list_versions_returns_list(self, mock_list_versions, mock_db):
act_id = str(uuid.uuid4())
mock_list_versions.return_value = [
{"id": str(uuid.uuid4()), "version_number": 2, "status": "draft",
"change_summary": "Updated name", "changed_sections": [],
"created_by": "admin", "approved_by": None, "approved_at": None,
"created_at": "2026-01-15T10:00:00"},
{"id": str(uuid.uuid4()), "version_number": 1, "status": "draft",
"change_summary": "Initial", "changed_sections": [],
"created_by": "system", "approved_by": None, "approved_at": None,
"created_at": "2026-01-14T09:00:00"},
]
resp = _client.get(f"/vvt/activities/{act_id}/versions")
assert resp.status_code == 200
data = resp.json()
assert len(data) == 2
assert data[0]["version_number"] == 2
assert data[1]["version_number"] == 1
@patch("compliance.api.versioning_utils.list_versions")
def test_list_versions_empty(self, mock_list_versions, mock_db):
act_id = str(uuid.uuid4())
mock_list_versions.return_value = []
resp = _client.get(f"/vvt/activities/{act_id}/versions")
assert resp.status_code == 200
assert resp.json() == []
@patch("compliance.api.versioning_utils.get_version")
def test_get_specific_version(self, mock_get_version, mock_db):
act_id = str(uuid.uuid4())
mock_get_version.return_value = {
"id": str(uuid.uuid4()),
"version_number": 1,
"status": "approved",
"snapshot": {"name": "Test", "status": "APPROVED"},
"change_summary": "Initial version",
"changed_sections": ["name", "status"],
"created_by": "admin",
"approved_by": "dpo",
"approved_at": "2026-01-16T12:00:00",
"created_at": "2026-01-15T10:00:00",
}
resp = _client.get(f"/vvt/activities/{act_id}/versions/1")
assert resp.status_code == 200
data = resp.json()
assert data["version_number"] == 1
assert data["snapshot"]["name"] == "Test"
assert data["approved_by"] == "dpo"
@patch("compliance.api.versioning_utils.get_version")
def test_get_version_not_found(self, mock_get_version, mock_db):
act_id = str(uuid.uuid4())
mock_get_version.return_value = None
resp = _client.get(f"/vvt/activities/{act_id}/versions/999")
assert resp.status_code == 404
assert "not found" in resp.json()["detail"].lower()
class TestExportCsvEdgeCases:
"""Additional edge cases for CSV export helper."""
def _collect_csv_body(self, response) -> str:
import asyncio
async def _read():
chunks = []
async for chunk in response.body_iterator:
chunks.append(chunk)
return ''.join(chunks)
return asyncio.get_event_loop().run_until_complete(_read())
def test_export_csv_with_third_country_transfers(self):
from compliance.api.vvt_routes import _export_csv
act = _make_db_activity(
third_country_transfers=["USA", "China"],
vvt_id="VVT-TC-001",
name="Third Country Test",
)
response = _export_csv([act])
text = self._collect_csv_body(response)
assert "Ja" in text # third_country_transfers truthy -> "Ja"
def test_export_csv_no_third_country_transfers(self):
from compliance.api.vvt_routes import _export_csv
act = _make_db_activity(
third_country_transfers=[],
vvt_id="VVT-NTC-001",
name="No Third Country",
)
response = _export_csv([act])
text = self._collect_csv_body(response)
assert "Nein" in text # empty list -> "Nein"
def test_export_csv_multiple_activities(self):
from compliance.api.vvt_routes import _export_csv
acts = [
_make_db_activity(vvt_id="VVT-M-001", name="First"),
_make_db_activity(vvt_id="VVT-M-002", name="Second"),
_make_db_activity(vvt_id="VVT-M-003", name="Third"),
]
response = _export_csv(acts)
text = self._collect_csv_body(response)
lines = text.strip().split('\n')
# 1 header + 3 data rows
assert len(lines) == 4
assert "VVT-M-001" in lines[1]
assert "VVT-M-002" in lines[2]
assert "VVT-M-003" in lines[3]
def test_export_csv_content_disposition_filename(self):
from compliance.api.vvt_routes import _export_csv
response = _export_csv([])
assert "vvt_export_" in response.headers.get("content-disposition", "")
assert ".csv" in response.headers.get("content-disposition", "")