"""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