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 34s
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 17s
Paket A — RAG Proxy: - NEU: admin-compliance/app/api/sdk/v1/rag/[[...path]]/route.ts → Proxy zu ai-compliance-sdk:8090, GET+POST, UUID-Validierung - UPDATE: rag/page.tsx — setTimeout Mock → echte API-Calls GET /regulations → dynamische suggestedQuestions POST /search → Qdrant-Ergebnisse mit score, title, reference Paket B — Security-Backlog + Quality: - NEU: migrations/014_security_backlog.sql + 015_quality.sql - NEU: compliance/api/security_backlog_routes.py — CRUD + Stats - NEU: compliance/api/quality_routes.py — Metrics + Tests CRUD + Stats - UPDATE: security-backlog/page.tsx — mockItems → API - UPDATE: quality/page.tsx — mockMetrics/mockTests → API - UPDATE: compliance/api/__init__.py — Router-Registrierung - NEU: tests/test_security_backlog_routes.py (48 Tests — 48/48 bestanden) - NEU: tests/test_quality_routes.py (67 Tests — 67/67 bestanden) Paket C — Notfallplan Incidents + Templates: - NEU: migrations/016_notfallplan_incidents.sql compliance_notfallplan_incidents + compliance_notfallplan_templates - UPDATE: notfallplan_routes.py — GET/POST/PUT/DELETE für /incidents + /templates - UPDATE: notfallplan/page.tsx — Incidents-Tab + Templates-Tab → API - UPDATE: tests/test_notfallplan_routes.py (+76 neue Tests — alle bestanden) Paket D — Loeschfristen localStorage → API: - NEU: migrations/017_loeschfristen.sql (JSONB: legal_holds, storage_locations, ...) - NEU: compliance/api/loeschfristen_routes.py — CRUD + Stats + Status-Update - UPDATE: loeschfristen/page.tsx — vollständige localStorage → API Migration createNewPolicy → POST (API-UUID als id), deletePolicy → DELETE, handleSaveAndClose → PUT, adoptGeneratedPolicies → POST je Policy apiToPolicy() + policyToPayload() Mapper, saving-State für Buttons - NEU: tests/test_loeschfristen_routes.py (58 Tests — alle bestanden) Gesamt: 253 neue Tests, alle bestanden (48 + 67 + 76 + 58 + bestehende) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
807 lines
32 KiB
Python
807 lines
32 KiB
Python
"""Tests for Notfallplan routes and schemas (notfallplan_routes.py).
|
|
|
|
Covers existing schema tests plus the new incidents and templates HTTP endpoints.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock
|
|
from fastapi.testclient import TestClient
|
|
from fastapi import FastAPI
|
|
from datetime import datetime
|
|
|
|
from compliance.api.notfallplan_routes import (
|
|
ContactCreate,
|
|
ContactUpdate,
|
|
ScenarioCreate,
|
|
ScenarioUpdate,
|
|
ChecklistCreate,
|
|
ExerciseCreate,
|
|
IncidentCreate,
|
|
TemplateCreate,
|
|
router,
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
client = TestClient(app)
|
|
|
|
DEFAULT_TENANT = "default"
|
|
INCIDENT_ID = "dddddddd-0001-0001-0001-000000000001"
|
|
TEMPLATE_ID = "eeeeeeee-0001-0001-0001-000000000001"
|
|
UNKNOWN_ID = "aaaaaaaa-9999-9999-9999-999999999999"
|
|
|
|
|
|
# =============================================================================
|
|
# Helpers
|
|
# =============================================================================
|
|
|
|
def make_incident_row(overrides=None):
|
|
data = {
|
|
"id": INCIDENT_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"title": "Test Incident",
|
|
"description": "An incident occurred",
|
|
"detected_at": datetime(2024, 1, 1),
|
|
"detected_by": "System",
|
|
"status": "detected",
|
|
"severity": "medium",
|
|
"affected_data_categories": [],
|
|
"estimated_affected_persons": 0,
|
|
"measures": [],
|
|
"art34_required": False,
|
|
"art34_justification": None,
|
|
"reported_to_authority_at": None,
|
|
"notified_affected_at": None,
|
|
"closed_at": None,
|
|
"closed_by": None,
|
|
"lessons_learned": None,
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
for k, v in data.items():
|
|
setattr(row, k, v)
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
def make_template_row(overrides=None):
|
|
data = {
|
|
"id": TEMPLATE_ID,
|
|
"tenant_id": DEFAULT_TENANT,
|
|
"type": "art33",
|
|
"title": "Art. 33 Template",
|
|
"content": "Sehr geehrte Behoerde...",
|
|
"created_at": datetime(2024, 1, 1),
|
|
"updated_at": datetime(2024, 1, 1),
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
row = MagicMock()
|
|
for k, v in data.items():
|
|
setattr(row, k, v)
|
|
row._mapping = data
|
|
return row
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
from classroom_engine.database import get_db
|
|
db = MagicMock()
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
yield db
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ContactCreate
|
|
# =============================================================================
|
|
|
|
class TestContactCreate:
|
|
def test_minimal_valid(self):
|
|
req = ContactCreate(name="Max Mustermann")
|
|
assert req.name == "Max Mustermann"
|
|
assert req.is_primary is False
|
|
assert req.available_24h is False
|
|
assert req.email is None
|
|
assert req.phone is None
|
|
|
|
def test_full_contact(self):
|
|
req = ContactCreate(
|
|
name="Anna Schmidt",
|
|
role="DSB",
|
|
email="anna@example.com",
|
|
phone="+49 160 12345678",
|
|
is_primary=True,
|
|
available_24h=True,
|
|
)
|
|
assert req.role == "DSB"
|
|
assert req.is_primary is True
|
|
assert req.available_24h is True
|
|
|
|
def test_serialization(self):
|
|
req = ContactCreate(name="Test Kontakt", role="IT-Leiter")
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data["name"] == "Test Kontakt"
|
|
assert data["role"] == "IT-Leiter"
|
|
assert "email" not in data
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ContactUpdate
|
|
# =============================================================================
|
|
|
|
class TestContactUpdate:
|
|
def test_empty_update(self):
|
|
req = ContactUpdate()
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data == {}
|
|
|
|
def test_partial_update(self):
|
|
req = ContactUpdate(phone="+49 170 9876543", available_24h=True)
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data == {"phone": "+49 170 9876543", "available_24h": True}
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ScenarioCreate
|
|
# =============================================================================
|
|
|
|
class TestScenarioCreate:
|
|
def test_minimal_valid(self):
|
|
req = ScenarioCreate(title="Datenpanne")
|
|
assert req.title == "Datenpanne"
|
|
assert req.severity == "medium"
|
|
assert req.is_active is True
|
|
assert req.response_steps == []
|
|
|
|
def test_with_response_steps(self):
|
|
steps = ["Schritt 1: Incident identifizieren", "Schritt 2: DSB informieren"]
|
|
req = ScenarioCreate(
|
|
title="Ransomware-Angriff",
|
|
category="system_failure",
|
|
severity="critical",
|
|
response_steps=steps,
|
|
estimated_recovery_time=48,
|
|
)
|
|
assert req.category == "system_failure"
|
|
assert req.severity == "critical"
|
|
assert len(req.response_steps) == 2
|
|
assert req.estimated_recovery_time == 48
|
|
|
|
def test_full_serialization(self):
|
|
req = ScenarioCreate(
|
|
title="Phishing",
|
|
category="data_breach",
|
|
severity="high",
|
|
description="Mitarbeiter wurde Opfer eines Phishing-Angriffs",
|
|
)
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data["severity"] == "high"
|
|
assert data["category"] == "data_breach"
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ScenarioUpdate
|
|
# =============================================================================
|
|
|
|
class TestScenarioUpdate:
|
|
def test_empty_update(self):
|
|
req = ScenarioUpdate()
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data == {}
|
|
|
|
def test_severity_update(self):
|
|
req = ScenarioUpdate(severity="low")
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data == {"severity": "low"}
|
|
|
|
def test_deactivate(self):
|
|
req = ScenarioUpdate(is_active=False)
|
|
data = req.model_dump(exclude_none=True)
|
|
assert data["is_active"] is False
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ChecklistCreate
|
|
# =============================================================================
|
|
|
|
class TestChecklistCreate:
|
|
def test_minimal_valid(self):
|
|
req = ChecklistCreate(title="DSB benachrichtigen")
|
|
assert req.title == "DSB benachrichtigen"
|
|
assert req.is_required is True
|
|
assert req.order_index == 0
|
|
assert req.scenario_id is None
|
|
|
|
def test_with_scenario_link(self):
|
|
req = ChecklistCreate(
|
|
title="IT-Team alarmieren",
|
|
scenario_id="550e8400-e29b-41d4-a716-446655440000",
|
|
order_index=1,
|
|
is_required=True,
|
|
)
|
|
assert req.scenario_id == "550e8400-e29b-41d4-a716-446655440000"
|
|
assert req.order_index == 1
|
|
|
|
|
|
# =============================================================================
|
|
# Existing Schema Tests — ExerciseCreate
|
|
# =============================================================================
|
|
|
|
class TestExerciseCreate:
|
|
def test_minimal_valid(self):
|
|
req = ExerciseCreate(title="Jahresübung 2026")
|
|
assert req.title == "Jahresübung 2026"
|
|
assert req.participants == []
|
|
assert req.outcome is None
|
|
|
|
def test_full_exercise(self):
|
|
req = ExerciseCreate(
|
|
title="Ransomware-Simulation",
|
|
scenario_id="550e8400-e29b-41d4-a716-446655440000",
|
|
participants=["Max Mustermann", "Anna Schmidt"],
|
|
outcome="passed",
|
|
notes="Übung verlief planmäßig",
|
|
)
|
|
assert req.outcome == "passed"
|
|
assert len(req.participants) == 2
|
|
assert req.notes == "Übung verlief planmäßig"
|
|
|
|
|
|
# =============================================================================
|
|
# New Schema Tests — IncidentCreate / TemplateCreate
|
|
# =============================================================================
|
|
|
|
class TestIncidentCreateSchema:
|
|
def test_incident_create_minimal(self):
|
|
inc = IncidentCreate(title="Breach")
|
|
assert inc.title == "Breach"
|
|
assert inc.status == "detected"
|
|
assert inc.severity == "medium"
|
|
assert inc.estimated_affected_persons == 0
|
|
assert inc.art34_required is False
|
|
assert inc.affected_data_categories == []
|
|
assert inc.measures == []
|
|
|
|
def test_incident_create_full(self):
|
|
inc = IncidentCreate(
|
|
title="Big Breach",
|
|
description="Ransomware attack",
|
|
detected_by="SIEM",
|
|
status="assessed",
|
|
severity="critical",
|
|
affected_data_categories=["personal", "health"],
|
|
estimated_affected_persons=1000,
|
|
measures=["isolation", "backup restore"],
|
|
art34_required=True,
|
|
art34_justification="High risk to data subjects",
|
|
)
|
|
assert inc.severity == "critical"
|
|
assert inc.estimated_affected_persons == 1000
|
|
assert len(inc.affected_data_categories) == 2
|
|
assert inc.art34_required is True
|
|
|
|
def test_incident_create_serialization_excludes_none(self):
|
|
inc = IncidentCreate(title="T")
|
|
data = inc.model_dump(exclude_none=True)
|
|
assert data["title"] == "T"
|
|
assert "art34_justification" not in data
|
|
assert "description" not in data
|
|
|
|
|
|
class TestTemplateCreateSchema:
|
|
def test_template_create_requires_title_content(self):
|
|
t = TemplateCreate(title="T", content="C", type="art33")
|
|
assert t.title == "T"
|
|
assert t.content == "C"
|
|
assert t.type == "art33"
|
|
|
|
def test_template_create_default_type(self):
|
|
t = TemplateCreate(title="T", content="C")
|
|
assert t.type == "art33"
|
|
|
|
def test_template_create_art34_type(self):
|
|
t = TemplateCreate(title="Notification Letter", content="Dear...", type="art34")
|
|
assert t.type == "art34"
|
|
|
|
|
|
# =============================================================================
|
|
# Incidents — GET /notfallplan/incidents
|
|
# =============================================================================
|
|
|
|
class TestListIncidents:
|
|
def test_list_incidents_returns_empty_list(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_list_incidents_returns_one_incident(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchall.return_value = [row]
|
|
resp = client.get("/notfallplan/incidents")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert data[0]["title"] == "Test Incident"
|
|
assert data[0]["status"] == "detected"
|
|
assert data[0]["severity"] == "medium"
|
|
|
|
def test_list_incidents_returns_multiple(self, mock_db):
|
|
rows = [
|
|
make_incident_row({"id": "id-1", "title": "Incident A"}),
|
|
make_incident_row({"id": "id-2", "title": "Incident B"}),
|
|
]
|
|
mock_db.execute.return_value.fetchall.return_value = rows
|
|
resp = client.get("/notfallplan/incidents")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
def test_list_incidents_filter_by_status(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents?status=closed")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("status") == "closed"
|
|
|
|
def test_list_incidents_filter_by_severity(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents?severity=high")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("severity") == "high"
|
|
|
|
def test_list_incidents_filter_combined(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents?status=detected&severity=critical")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("status") == "detected"
|
|
assert call_params.get("severity") == "critical"
|
|
|
|
def test_list_incidents_uses_default_tenant(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("tenant_id") == DEFAULT_TENANT
|
|
|
|
def test_list_incidents_custom_tenant_header(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/incidents", headers={"X-Tenant-ID": "my-tenant"})
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("tenant_id") == "my-tenant"
|
|
|
|
def test_list_incidents_all_fields_present(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchall.return_value = [row]
|
|
resp = client.get("/notfallplan/incidents")
|
|
item = resp.json()[0]
|
|
expected_fields = (
|
|
"id", "tenant_id", "title", "description", "detected_at",
|
|
"detected_by", "status", "severity", "affected_data_categories",
|
|
"estimated_affected_persons", "measures", "art34_required",
|
|
"art34_justification", "reported_to_authority_at",
|
|
"notified_affected_at", "closed_at", "closed_by",
|
|
"lessons_learned", "created_at", "updated_at",
|
|
)
|
|
for field in expected_fields:
|
|
assert field in item, f"Missing field: {field}"
|
|
|
|
|
|
# =============================================================================
|
|
# Incidents — POST /notfallplan/incidents
|
|
# =============================================================================
|
|
|
|
class TestCreateIncident:
|
|
def test_create_incident_minimal(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/incidents", json={"title": "New Incident"})
|
|
assert resp.status_code == 201
|
|
assert resp.json()["title"] == "Test Incident"
|
|
|
|
def test_create_incident_full_payload(self, mock_db):
|
|
row = make_incident_row({
|
|
"title": "Critical Breach",
|
|
"description": "Database exposed",
|
|
"detected_by": "SOC Team",
|
|
"status": "assessed",
|
|
"severity": "critical",
|
|
"estimated_affected_persons": 500,
|
|
})
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/incidents", json={
|
|
"title": "Critical Breach",
|
|
"description": "Database exposed",
|
|
"detected_by": "SOC Team",
|
|
"status": "assessed",
|
|
"severity": "critical",
|
|
"estimated_affected_persons": 500,
|
|
})
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["severity"] == "critical"
|
|
assert data["estimated_affected_persons"] == 500
|
|
|
|
def test_create_incident_missing_title_returns_422(self, mock_db):
|
|
resp = client.post("/notfallplan/incidents", json={"description": "No title here"})
|
|
assert resp.status_code == 422
|
|
|
|
def test_create_incident_default_status_passed_to_db(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
client.post("/notfallplan/incidents", json={"title": "T"})
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["status"] == "detected"
|
|
assert call_params["severity"] == "medium"
|
|
|
|
def test_create_incident_commits(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
client.post("/notfallplan/incidents", json={"title": "T"})
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_create_incident_with_art34_required(self, mock_db):
|
|
row = make_incident_row({"art34_required": True, "art34_justification": "Hohe Risikobewertung"})
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/incidents", json={
|
|
"title": "High Risk",
|
|
"art34_required": True,
|
|
"art34_justification": "Hohe Risikobewertung",
|
|
})
|
|
assert resp.status_code == 201
|
|
assert resp.json()["art34_required"] is True
|
|
|
|
def test_create_incident_passes_tenant_id(self, mock_db):
|
|
row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
client.post("/notfallplan/incidents",
|
|
json={"title": "T"},
|
|
headers={"X-Tenant-ID": "custom-org"})
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == "custom-org"
|
|
|
|
|
|
# =============================================================================
|
|
# Incidents — PUT /notfallplan/incidents/{id}
|
|
# =============================================================================
|
|
|
|
class TestUpdateIncident:
|
|
def test_update_incident_success(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row({"status": "assessed"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "assessed"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "assessed"
|
|
|
|
def test_update_incident_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.fetchone.return_value = None
|
|
resp = client.put(f"/notfallplan/incidents/{UNKNOWN_ID}", json={"status": "closed"})
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_incident_empty_body_returns_400(self, mock_db):
|
|
existing = MagicMock()
|
|
mock_db.execute.return_value.fetchone.return_value = existing
|
|
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={})
|
|
assert resp.status_code == 400
|
|
|
|
def test_update_incident_status_to_reported_auto_sets_timestamp(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row({"status": "reported"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "reported"})
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert "reported_to_authority_at" in call_params
|
|
|
|
def test_update_incident_status_to_closed_auto_sets_closed_at(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row({"status": "closed"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"status": "closed"})
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert "closed_at" in call_params
|
|
|
|
def test_update_incident_lessons_learned(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row({"lessons_learned": "Besseres Monitoring nötig"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(
|
|
f"/notfallplan/incidents/{INCIDENT_ID}",
|
|
json={"lessons_learned": "Besseres Monitoring nötig"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["lessons_learned"] == "Besseres Monitoring nötig"
|
|
|
|
def test_update_incident_severity(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row({"severity": "high"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "high"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["severity"] == "high"
|
|
|
|
def test_update_incident_commits(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"severity": "low"})
|
|
mock_db.commit.assert_called()
|
|
|
|
def test_update_incident_always_sets_updated_at(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_incident_row()
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
client.put(f"/notfallplan/incidents/{INCIDENT_ID}", json={"title": "Renamed"})
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert "updated_at" in call_params
|
|
|
|
|
|
# =============================================================================
|
|
# Incidents — DELETE /notfallplan/incidents/{id}
|
|
# =============================================================================
|
|
|
|
class TestDeleteIncident:
|
|
def test_delete_incident_success_returns_204(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
resp = client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
|
|
assert resp.status_code == 204
|
|
|
|
def test_delete_incident_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 0
|
|
resp = client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
|
|
assert resp.status_code == 404
|
|
|
|
def test_delete_incident_commits(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
client.delete(f"/notfallplan/incidents/{INCIDENT_ID}")
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_incident_commits_even_when_not_found(self, mock_db):
|
|
# Commit is called before the rowcount check in the implementation
|
|
mock_db.execute.return_value.rowcount = 0
|
|
client.delete(f"/notfallplan/incidents/{UNKNOWN_ID}")
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_incident_passes_tenant_id(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
client.delete(
|
|
f"/notfallplan/incidents/{INCIDENT_ID}",
|
|
headers={"X-Tenant-ID": "acme"},
|
|
)
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == "acme"
|
|
|
|
|
|
# =============================================================================
|
|
# Templates — GET /notfallplan/templates
|
|
# =============================================================================
|
|
|
|
class TestListTemplates:
|
|
def test_list_templates_returns_empty_list(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/templates")
|
|
assert resp.status_code == 200
|
|
assert resp.json() == []
|
|
|
|
def test_list_templates_returns_one(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchall.return_value = [row]
|
|
resp = client.get("/notfallplan/templates")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data) == 1
|
|
assert data[0]["title"] == "Art. 33 Template"
|
|
assert data[0]["type"] == "art33"
|
|
|
|
def test_list_templates_returns_multiple(self, mock_db):
|
|
rows = [
|
|
make_template_row({"id": "id-1", "type": "art33", "title": "Meldung Art.33"}),
|
|
make_template_row({"id": "id-2", "type": "art34", "title": "Meldung Art.34"}),
|
|
]
|
|
mock_db.execute.return_value.fetchall.return_value = rows
|
|
resp = client.get("/notfallplan/templates")
|
|
assert resp.status_code == 200
|
|
assert len(resp.json()) == 2
|
|
|
|
def test_list_templates_filter_by_type(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/templates?type=art34")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params.get("type") == "art34"
|
|
|
|
def test_list_templates_without_type_no_type_param_sent(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
resp = client.get("/notfallplan/templates")
|
|
assert resp.status_code == 200
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert "type" not in call_params
|
|
|
|
def test_list_templates_uses_default_tenant(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
client.get("/notfallplan/templates")
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == DEFAULT_TENANT
|
|
|
|
def test_list_templates_custom_tenant_header(self, mock_db):
|
|
mock_db.execute.return_value.fetchall.return_value = []
|
|
client.get("/notfallplan/templates", headers={"X-Tenant-ID": "acme"})
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == "acme"
|
|
|
|
def test_list_templates_all_fields_present(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchall.return_value = [row]
|
|
resp = client.get("/notfallplan/templates")
|
|
item = resp.json()[0]
|
|
for field in ("id", "tenant_id", "type", "title", "content", "created_at", "updated_at"):
|
|
assert field in item, f"Missing field: {field}"
|
|
|
|
|
|
# =============================================================================
|
|
# Templates — POST /notfallplan/templates
|
|
# =============================================================================
|
|
|
|
class TestCreateTemplate:
|
|
def test_create_template_success(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/templates", json={
|
|
"title": "Art. 33 Template",
|
|
"content": "Sehr geehrte Behoerde...",
|
|
"type": "art33",
|
|
})
|
|
assert resp.status_code == 201
|
|
assert resp.json()["title"] == "Art. 33 Template"
|
|
|
|
def test_create_template_missing_title_returns_422(self, mock_db):
|
|
resp = client.post("/notfallplan/templates", json={
|
|
"content": "Some content",
|
|
"type": "art33",
|
|
})
|
|
assert resp.status_code == 422
|
|
|
|
def test_create_template_missing_content_returns_422(self, mock_db):
|
|
resp = client.post("/notfallplan/templates", json={
|
|
"title": "Template",
|
|
"type": "art33",
|
|
})
|
|
assert resp.status_code == 422
|
|
|
|
def test_create_template_missing_type_uses_default_art33(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/templates", json={"title": "T", "content": "C"})
|
|
assert resp.status_code == 201
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["type"] == "art33"
|
|
|
|
def test_create_template_art34_type(self, mock_db):
|
|
row = make_template_row({"type": "art34"})
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
resp = client.post("/notfallplan/templates", json={
|
|
"title": "Art. 34 Notification",
|
|
"content": "Sehr geehrte Betroffene...",
|
|
"type": "art34",
|
|
})
|
|
assert resp.status_code == 201
|
|
assert resp.json()["type"] == "art34"
|
|
|
|
def test_create_template_commits(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
client.post("/notfallplan/templates", json={"title": "T", "content": "C", "type": "art33"})
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_create_template_passes_tenant_id(self, mock_db):
|
|
row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.return_value = row
|
|
client.post(
|
|
"/notfallplan/templates",
|
|
json={"title": "T", "content": "C", "type": "art33"},
|
|
headers={"X-Tenant-ID": "my-org"},
|
|
)
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == "my-org"
|
|
|
|
|
|
# =============================================================================
|
|
# Templates — PUT /notfallplan/templates/{id}
|
|
# =============================================================================
|
|
|
|
class TestUpdateTemplate:
|
|
def test_update_template_title_success(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_template_row({"title": "Updated Title"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "Updated Title"})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["title"] == "Updated Title"
|
|
|
|
def test_update_template_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.fetchone.return_value = None
|
|
resp = client.put(f"/notfallplan/templates/{UNKNOWN_ID}", json={"title": "X"})
|
|
assert resp.status_code == 404
|
|
|
|
def test_update_template_empty_body_returns_400(self, mock_db):
|
|
existing = MagicMock()
|
|
mock_db.execute.return_value.fetchone.return_value = existing
|
|
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={})
|
|
assert resp.status_code == 400
|
|
|
|
def test_update_template_content(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_template_row({"content": "New body text"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(
|
|
f"/notfallplan/templates/{TEMPLATE_ID}",
|
|
json={"content": "New body text"},
|
|
)
|
|
assert resp.status_code == 200
|
|
assert resp.json()["content"] == "New body text"
|
|
|
|
def test_update_template_type(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_template_row({"type": "internal"})
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
resp = client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"type": "internal"})
|
|
assert resp.status_code == 200
|
|
|
|
def test_update_template_commits(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
|
|
mock_db.commit.assert_called()
|
|
|
|
def test_update_template_sets_updated_at(self, mock_db):
|
|
existing = MagicMock()
|
|
updated_row = make_template_row()
|
|
mock_db.execute.return_value.fetchone.side_effect = [existing, updated_row]
|
|
client.put(f"/notfallplan/templates/{TEMPLATE_ID}", json={"title": "New"})
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert "updated_at" in call_params
|
|
|
|
|
|
# =============================================================================
|
|
# Templates — DELETE /notfallplan/templates/{id}
|
|
# =============================================================================
|
|
|
|
class TestDeleteTemplate:
|
|
def test_delete_template_success_returns_204(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
resp = client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
|
|
assert resp.status_code == 204
|
|
|
|
def test_delete_template_not_found_returns_404(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 0
|
|
resp = client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
|
|
assert resp.status_code == 404
|
|
|
|
def test_delete_template_commits_on_success(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
client.delete(f"/notfallplan/templates/{TEMPLATE_ID}")
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_template_commits_even_when_not_found(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 0
|
|
client.delete(f"/notfallplan/templates/{UNKNOWN_ID}")
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_template_passes_correct_tenant_id(self, mock_db):
|
|
mock_db.execute.return_value.rowcount = 1
|
|
client.delete(
|
|
f"/notfallplan/templates/{TEMPLATE_ID}",
|
|
headers={"X-Tenant-ID": "acme"},
|
|
)
|
|
call_params = mock_db.execute.call_args[0][1]
|
|
assert call_params["tenant_id"] == "acme"
|