All checks were successful
CI/CD / go-lint (push) Has been skipped
CI/CD / python-lint (push) Has been skipped
CI/CD / nodejs-lint (push) Has been skipped
CI/CD / test-go-ai-compliance (push) Successful in 32s
CI/CD / test-python-backend-compliance (push) Successful in 34s
CI/CD / test-python-document-crawler (push) Successful in 23s
CI/CD / test-python-dsms-gateway (push) Successful in 21s
CI/CD / validate-canonical-controls (push) Successful in 11s
CI/CD / Deploy (push) Successful in 2s
Module 2: Extended Compliance Dashboard with roadmap, module-status, next-actions, snapshots, score-history Module 3: 7 German security document templates (IT-Sicherheitskonzept, Datenschutz, Backup, Logging, Incident-Response, Zugriff, Risikomanagement) Module 4: Compliance Process Manager with CRUD, complete/skip/seed, ~50 seed tasks, 3-tab UI Module 5: Evidence Collector Extended with automated checks, control-mapping, coverage report, 4-tab UI Also includes: canonical control library enhancements (verification method, categories, dedup), control generator improvements, RAG client extensions 52 tests pass, frontend builds clean. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
526 lines
19 KiB
Python
526 lines
19 KiB
Python
"""Tests for compliance process task routes (process_task_routes.py)."""
|
|
|
|
import pytest
|
|
from unittest.mock import MagicMock, patch, call
|
|
from datetime import datetime, date, timedelta
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from compliance.api.process_task_routes import (
|
|
router,
|
|
ProcessTaskCreate,
|
|
ProcessTaskUpdate,
|
|
ProcessTaskComplete,
|
|
ProcessTaskSkip,
|
|
VALID_CATEGORIES,
|
|
VALID_FREQUENCIES,
|
|
VALID_PRIORITIES,
|
|
VALID_STATUSES,
|
|
FREQUENCY_DAYS,
|
|
)
|
|
from classroom_engine.database import get_db
|
|
from compliance.api.tenant_utils import get_tenant_id
|
|
|
|
DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e"
|
|
TASK_ID = "ffffffff-0001-0001-0001-000000000001"
|
|
|
|
app = FastAPI()
|
|
app.include_router(router)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Mock helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _MockRow:
|
|
"""Simulates a SQLAlchemy row with _mapping attribute."""
|
|
def __init__(self, data: dict):
|
|
self._mapping = data
|
|
|
|
def __getitem__(self, idx):
|
|
vals = list(self._mapping.values())
|
|
return vals[idx]
|
|
|
|
|
|
def _make_task_row(overrides=None):
|
|
now = datetime(2026, 3, 14, 12, 0, 0)
|
|
data = {
|
|
"id": TASK_ID,
|
|
"tenant_id": DEFAULT_TENANT_ID,
|
|
"project_id": None,
|
|
"task_code": "DSGVO-VVT-REVIEW",
|
|
"title": "VVT-Review und Aktualisierung",
|
|
"description": "Jaehrliche Ueberpruefung des VVT.",
|
|
"category": "dsgvo",
|
|
"priority": "high",
|
|
"frequency": "yearly",
|
|
"assigned_to": None,
|
|
"responsible_team": None,
|
|
"linked_control_ids": [],
|
|
"linked_module": "vvt",
|
|
"last_completed_at": None,
|
|
"next_due_date": date(2027, 3, 14),
|
|
"due_reminder_days": 14,
|
|
"status": "pending",
|
|
"completion_date": None,
|
|
"completion_result": None,
|
|
"completion_evidence_id": None,
|
|
"follow_up_actions": [],
|
|
"is_seed": False,
|
|
"notes": None,
|
|
"tags": [],
|
|
"created_at": now,
|
|
"updated_at": now,
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
return _MockRow(data)
|
|
|
|
|
|
def _make_history_row(overrides=None):
|
|
now = datetime(2026, 3, 14, 12, 0, 0)
|
|
data = {
|
|
"id": "eeeeeeee-0001-0001-0001-000000000001",
|
|
"task_id": TASK_ID,
|
|
"completed_by": "admin",
|
|
"completed_at": now,
|
|
"result": "Alles in Ordnung",
|
|
"evidence_id": None,
|
|
"notes": "Keine Auffaelligkeiten",
|
|
"status": "completed",
|
|
}
|
|
if overrides:
|
|
data.update(overrides)
|
|
return _MockRow(data)
|
|
|
|
|
|
def _count_row(val):
|
|
"""Simulates a COUNT(*) row — fetchone()[0] returns the value."""
|
|
row = MagicMock()
|
|
row.__getitem__ = lambda self, idx: val
|
|
return row
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_db():
|
|
db = MagicMock()
|
|
app.dependency_overrides[get_db] = lambda: db
|
|
yield db
|
|
app.dependency_overrides.pop(get_db, None)
|
|
|
|
|
|
@pytest.fixture
|
|
def client(mock_db):
|
|
return TestClient(app)
|
|
|
|
|
|
# =============================================================================
|
|
# Test 1: List Tasks
|
|
# =============================================================================
|
|
|
|
class TestListTasks:
|
|
def test_list_tasks(self, client, mock_db):
|
|
"""List tasks returns items and total."""
|
|
row = _make_task_row()
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
|
MagicMock(fetchall=MagicMock(return_value=[row])),
|
|
]
|
|
resp = client.get("/process-tasks")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 1
|
|
assert len(data["tasks"]) == 1
|
|
assert data["tasks"][0]["id"] == TASK_ID
|
|
assert data["tasks"][0]["task_code"] == "DSGVO-VVT-REVIEW"
|
|
|
|
def test_list_tasks_empty(self, client, mock_db):
|
|
"""List tasks returns empty when no tasks."""
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
|
|
MagicMock(fetchall=MagicMock(return_value=[])),
|
|
]
|
|
resp = client.get("/process-tasks")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 0
|
|
assert data["tasks"] == []
|
|
|
|
|
|
# =============================================================================
|
|
# Test 2: List Tasks with Filters
|
|
# =============================================================================
|
|
|
|
class TestListTasksWithFilters:
|
|
def test_list_tasks_with_filters(self, client, mock_db):
|
|
"""Filter by status and category."""
|
|
row = _make_task_row({"status": "overdue", "category": "nis2"})
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=_count_row(1))),
|
|
MagicMock(fetchall=MagicMock(return_value=[row])),
|
|
]
|
|
resp = client.get("/process-tasks?status=overdue&category=nis2")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["tasks"][0]["status"] == "overdue"
|
|
assert data["tasks"][0]["category"] == "nis2"
|
|
|
|
def test_list_tasks_overdue_filter(self, client, mock_db):
|
|
"""Filter overdue=true adds date condition."""
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=_count_row(0))),
|
|
MagicMock(fetchall=MagicMock(return_value=[])),
|
|
]
|
|
resp = client.get("/process-tasks?overdue=true")
|
|
assert resp.status_code == 200
|
|
# Verify the SQL was called (mock_db.execute called twice: count + select)
|
|
assert mock_db.execute.call_count == 2
|
|
|
|
|
|
# =============================================================================
|
|
# Test 3: Get Stats
|
|
# =============================================================================
|
|
|
|
class TestGetStats:
|
|
def test_get_stats(self, client, mock_db):
|
|
"""Verify stat counts structure."""
|
|
stats_row = MagicMock()
|
|
stats_row._mapping = {
|
|
"total": 50,
|
|
"pending": 20,
|
|
"in_progress": 5,
|
|
"completed": 15,
|
|
"overdue": 8,
|
|
"skipped": 2,
|
|
"overdue_count": 8,
|
|
"due_7_days": 3,
|
|
"due_14_days": 7,
|
|
"due_30_days": 12,
|
|
}
|
|
cat_row1 = MagicMock()
|
|
cat_row1._mapping = {"category": "dsgvo", "cnt": 15}
|
|
cat_row2 = MagicMock()
|
|
cat_row2._mapping = {"category": "nis2", "cnt": 10}
|
|
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=stats_row)),
|
|
MagicMock(fetchall=MagicMock(return_value=[cat_row1, cat_row2])),
|
|
]
|
|
resp = client.get("/process-tasks/stats")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 50
|
|
assert data["by_status"]["pending"] == 20
|
|
assert data["by_status"]["completed"] == 15
|
|
assert data["overdue_count"] == 8
|
|
assert data["due_7_days"] == 3
|
|
assert data["due_30_days"] == 12
|
|
assert data["by_category"]["dsgvo"] == 15
|
|
assert data["by_category"]["nis2"] == 10
|
|
|
|
def test_get_stats_empty(self, client, mock_db):
|
|
"""Stats with no tasks returns zeros."""
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=None)),
|
|
MagicMock(fetchall=MagicMock(return_value=[])),
|
|
]
|
|
resp = client.get("/process-tasks/stats")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["total"] == 0
|
|
assert data["by_category"] == {}
|
|
|
|
|
|
# =============================================================================
|
|
# Test 4: Create Task
|
|
# =============================================================================
|
|
|
|
class TestCreateTask:
|
|
def test_create_task(self, client, mock_db):
|
|
"""Create a valid task returns 201."""
|
|
row = _make_task_row()
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
|
resp = client.post("/process-tasks", json={
|
|
"task_code": "DSGVO-VVT-REVIEW",
|
|
"title": "VVT-Review und Aktualisierung",
|
|
"category": "dsgvo",
|
|
"priority": "high",
|
|
"frequency": "yearly",
|
|
})
|
|
assert resp.status_code == 201
|
|
data = resp.json()
|
|
assert data["id"] == TASK_ID
|
|
assert data["task_code"] == "DSGVO-VVT-REVIEW"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
|
|
# =============================================================================
|
|
# Test 5: Create Task Invalid Category
|
|
# =============================================================================
|
|
|
|
class TestCreateTaskInvalidCategory:
|
|
def test_create_task_invalid_category(self, client, mock_db):
|
|
"""Invalid category returns 400."""
|
|
resp = client.post("/process-tasks", json={
|
|
"task_code": "TEST-001",
|
|
"title": "Test",
|
|
"category": "invalid_category",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert "Invalid category" in resp.json()["detail"]
|
|
|
|
def test_create_task_invalid_priority(self, client, mock_db):
|
|
"""Invalid priority returns 400."""
|
|
resp = client.post("/process-tasks", json={
|
|
"task_code": "TEST-001",
|
|
"title": "Test",
|
|
"category": "dsgvo",
|
|
"priority": "super_high",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert "Invalid priority" in resp.json()["detail"]
|
|
|
|
def test_create_task_invalid_frequency(self, client, mock_db):
|
|
"""Invalid frequency returns 400."""
|
|
resp = client.post("/process-tasks", json={
|
|
"task_code": "TEST-001",
|
|
"title": "Test",
|
|
"category": "dsgvo",
|
|
"frequency": "biweekly",
|
|
})
|
|
assert resp.status_code == 400
|
|
assert "Invalid frequency" in resp.json()["detail"]
|
|
|
|
|
|
# =============================================================================
|
|
# Test 6: Get Single Task
|
|
# =============================================================================
|
|
|
|
class TestGetSingleTask:
|
|
def test_get_single_task(self, client, mock_db):
|
|
"""Get existing task by ID."""
|
|
row = _make_task_row()
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=row))
|
|
resp = client.get(f"/process-tasks/{TASK_ID}")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["id"] == TASK_ID
|
|
|
|
def test_get_task_not_found(self, client, mock_db):
|
|
"""Get non-existent task returns 404."""
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
|
resp = client.get("/process-tasks/nonexistent-id")
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# =============================================================================
|
|
# Test 7: Complete Task
|
|
# =============================================================================
|
|
|
|
class TestCompleteTask:
|
|
def test_complete_task(self, client, mock_db):
|
|
"""Complete a task: verify history insert and next_due recalculation."""
|
|
task_row = _make_task_row({"frequency": "quarterly"})
|
|
updated_row = _make_task_row({
|
|
"frequency": "quarterly",
|
|
"status": "pending",
|
|
"last_completed_at": datetime(2026, 3, 14, 12, 0, 0),
|
|
"next_due_date": date(2026, 6, 12),
|
|
})
|
|
|
|
# First call: SELECT task, Second: INSERT history, Third: UPDATE task
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=task_row)),
|
|
MagicMock(), # history INSERT
|
|
MagicMock(fetchone=MagicMock(return_value=updated_row)),
|
|
]
|
|
|
|
resp = client.post(f"/process-tasks/{TASK_ID}/complete", json={
|
|
"completed_by": "admin",
|
|
"result": "Alles geprueft",
|
|
"notes": "Keine Auffaelligkeiten",
|
|
})
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "pending" # Reset for recurring
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_complete_once_task(self, client, mock_db):
|
|
"""Complete a one-time task stays completed."""
|
|
task_row = _make_task_row({"frequency": "once"})
|
|
updated_row = _make_task_row({
|
|
"frequency": "once",
|
|
"status": "completed",
|
|
"next_due_date": None,
|
|
})
|
|
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=task_row)),
|
|
MagicMock(),
|
|
MagicMock(fetchone=MagicMock(return_value=updated_row)),
|
|
]
|
|
|
|
resp = client.post(f"/process-tasks/{TASK_ID}/complete", json={
|
|
"completed_by": "admin",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "completed"
|
|
|
|
def test_complete_task_not_found(self, client, mock_db):
|
|
"""Complete non-existent task returns 404."""
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
|
resp = client.post("/process-tasks/nonexistent-id/complete", json={
|
|
"completed_by": "admin",
|
|
})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# =============================================================================
|
|
# Test 8: Skip Task
|
|
# =============================================================================
|
|
|
|
class TestSkipTask:
|
|
def test_skip_task(self, client, mock_db):
|
|
"""Skip task with reason, verify next_due recalculation."""
|
|
task_row = _make_task_row({"frequency": "monthly"})
|
|
updated_row = _make_task_row({
|
|
"frequency": "monthly",
|
|
"status": "pending",
|
|
"next_due_date": date(2026, 4, 13),
|
|
})
|
|
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=task_row)),
|
|
MagicMock(), # history INSERT
|
|
MagicMock(fetchone=MagicMock(return_value=updated_row)),
|
|
]
|
|
|
|
resp = client.post(f"/process-tasks/{TASK_ID}/skip", json={
|
|
"reason": "Kein Handlungsbedarf diesen Monat",
|
|
})
|
|
assert resp.status_code == 200
|
|
assert resp.json()["status"] == "pending"
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_skip_task_not_found(self, client, mock_db):
|
|
"""Skip non-existent task returns 404."""
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
|
resp = client.post("/process-tasks/nonexistent-id/skip", json={
|
|
"reason": "Test",
|
|
})
|
|
assert resp.status_code == 404
|
|
|
|
|
|
# =============================================================================
|
|
# Test 9: Seed Idempotent
|
|
# =============================================================================
|
|
|
|
class TestSeedIdempotent:
|
|
def test_seed_idempotent(self, client, mock_db):
|
|
"""Seed twice — ON CONFLICT ensures idempotency."""
|
|
# First seed: all inserted (rowcount=1 for each)
|
|
mock_result = MagicMock()
|
|
mock_result.rowcount = 1
|
|
mock_db.execute.return_value = mock_result
|
|
|
|
resp = client.post("/process-tasks/seed")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["seeded"] == data["total_available"]
|
|
assert data["total_available"] == 50
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_seed_second_time_no_inserts(self, client, mock_db):
|
|
"""Second seed inserts nothing (ON CONFLICT DO NOTHING)."""
|
|
mock_result = MagicMock()
|
|
mock_result.rowcount = 0
|
|
mock_db.execute.return_value = mock_result
|
|
|
|
resp = client.post("/process-tasks/seed")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["seeded"] == 0
|
|
assert data["total_available"] == 50
|
|
|
|
|
|
# =============================================================================
|
|
# Test 10: Get History
|
|
# =============================================================================
|
|
|
|
class TestGetHistory:
|
|
def test_get_history(self, client, mock_db):
|
|
"""Return history entries for a task."""
|
|
task_id_row = _MockRow({"id": TASK_ID})
|
|
history_row = _make_history_row()
|
|
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=task_id_row)),
|
|
MagicMock(fetchall=MagicMock(return_value=[history_row])),
|
|
]
|
|
|
|
resp = client.get(f"/process-tasks/{TASK_ID}/history")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert len(data["history"]) == 1
|
|
assert data["history"][0]["task_id"] == TASK_ID
|
|
assert data["history"][0]["status"] == "completed"
|
|
assert data["history"][0]["completed_by"] == "admin"
|
|
|
|
def test_get_history_task_not_found(self, client, mock_db):
|
|
"""History for non-existent task returns 404."""
|
|
mock_db.execute.return_value = MagicMock(fetchone=MagicMock(return_value=None))
|
|
resp = client.get("/process-tasks/nonexistent-id/history")
|
|
assert resp.status_code == 404
|
|
|
|
def test_get_history_empty(self, client, mock_db):
|
|
"""Task with no history returns empty list."""
|
|
task_id_row = _MockRow({"id": TASK_ID})
|
|
|
|
mock_db.execute.side_effect = [
|
|
MagicMock(fetchone=MagicMock(return_value=task_id_row)),
|
|
MagicMock(fetchall=MagicMock(return_value=[])),
|
|
]
|
|
|
|
resp = client.get(f"/process-tasks/{TASK_ID}/history")
|
|
assert resp.status_code == 200
|
|
assert resp.json()["history"] == []
|
|
|
|
|
|
# =============================================================================
|
|
# Constant / Schema Validation Tests
|
|
# =============================================================================
|
|
|
|
class TestConstants:
|
|
def test_valid_categories(self):
|
|
assert VALID_CATEGORIES == {"dsgvo", "nis2", "bsi", "iso27001", "ai_act", "internal"}
|
|
|
|
def test_valid_frequencies(self):
|
|
assert VALID_FREQUENCIES == {"weekly", "monthly", "quarterly", "semi_annual", "yearly", "once"}
|
|
|
|
def test_valid_priorities(self):
|
|
assert VALID_PRIORITIES == {"critical", "high", "medium", "low"}
|
|
|
|
def test_valid_statuses(self):
|
|
assert VALID_STATUSES == {"pending", "in_progress", "completed", "overdue", "skipped"}
|
|
|
|
def test_frequency_days_mapping(self):
|
|
assert FREQUENCY_DAYS["weekly"] == 7
|
|
assert FREQUENCY_DAYS["monthly"] == 30
|
|
assert FREQUENCY_DAYS["quarterly"] == 90
|
|
assert FREQUENCY_DAYS["semi_annual"] == 182
|
|
assert FREQUENCY_DAYS["yearly"] == 365
|
|
assert FREQUENCY_DAYS["once"] is None
|
|
|
|
|
|
class TestDeleteTask:
|
|
def test_delete_existing(self, client, mock_db):
|
|
mock_db.execute.return_value = MagicMock(rowcount=1)
|
|
resp = client.delete(f"/process-tasks/{TASK_ID}")
|
|
assert resp.status_code == 204
|
|
mock_db.commit.assert_called_once()
|
|
|
|
def test_delete_not_found(self, client, mock_db):
|
|
mock_db.execute.return_value = MagicMock(rowcount=0)
|
|
resp = client.delete(f"/process-tasks/{TASK_ID}")
|
|
assert resp.status_code == 404
|