"""Tests for extended dashboard routes (roadmap, module-status, next-actions, snapshots).""" import pytest from unittest.mock import MagicMock, patch from fastapi.testclient import TestClient from fastapi import FastAPI from datetime import datetime, date, timedelta from decimal import Decimal from compliance.api.dashboard_routes import router from classroom_engine.database import get_db from compliance.api.tenant_utils import get_tenant_id DEFAULT_TENANT_ID = "9282a473-5c95-4b3a-bf78-0ecc0ec71d3e" # ============================================================================= # Test App Setup # ============================================================================= app = FastAPI() app.include_router(router) mock_db = MagicMock() def override_get_db(): yield mock_db def override_tenant(): return DEFAULT_TENANT_ID app.dependency_overrides[get_db] = override_get_db app.dependency_overrides[get_tenant_id] = override_tenant client = TestClient(app) # ============================================================================= # Helpers # ============================================================================= class MockControl: def __init__(self, id="ctrl-001", control_id="CTRL-001", title="Test Control", status_val="planned", domain_val="gov", owner="ISB", next_review_at=None, category="security"): self.id = id self.control_id = control_id self.title = title self.status = MagicMock(value=status_val) self.domain = MagicMock(value=domain_val) self.owner = owner self.next_review_at = next_review_at self.category = category class MockRisk: def __init__(self, inherent_risk_val="high", status="open"): self.inherent_risk = MagicMock(value=inherent_risk_val) self.status = status def make_snapshot_row(overrides=None): data = { "id": "snap-001", "tenant_id": DEFAULT_TENANT_ID, "project_id": None, "score": Decimal("72.50"), "controls_total": 20, "controls_pass": 12, "controls_partial": 5, "evidence_total": 10, "evidence_valid": 8, "risks_total": 5, "risks_high": 2, "snapshot_date": date(2026, 3, 14), "created_at": datetime(2026, 3, 14), } if overrides: data.update(overrides) row = MagicMock() row._mapping = data return row # ============================================================================= # Tests # ============================================================================= class TestDashboardRoadmap: def test_roadmap_returns_buckets(self): """Roadmap returns 4 buckets with controls.""" overdue = datetime.utcnow() - timedelta(days=10) future = datetime.utcnow() + timedelta(days=30) with patch("compliance.api.dashboard_routes.ControlRepository") as MockCtrlRepo: instance = MockCtrlRepo.return_value instance.get_all.return_value = [ MockControl(id="c1", status_val="planned", category="legal", next_review_at=overdue), MockControl(id="c2", status_val="partial", category="security"), MockControl(id="c3", status_val="planned", category="best_practice"), MockControl(id="c4", status_val="pass"), # should be excluded ] resp = client.get("/dashboard/roadmap") assert resp.status_code == 200 data = resp.json() assert "buckets" in data assert "counts" in data # c4 is pass, so excluded; c1 is legal+overdue → quick_wins total_in_buckets = sum(data["counts"].values()) assert total_in_buckets == 3 def test_roadmap_empty_controls(self): """Roadmap with no controls returns empty buckets.""" with patch("compliance.api.dashboard_routes.ControlRepository") as MockCtrlRepo: MockCtrlRepo.return_value.get_all.return_value = [] resp = client.get("/dashboard/roadmap") assert resp.status_code == 200 assert all(v == 0 for v in resp.json()["counts"].values()) class TestModuleStatus: def test_module_status_returns_modules(self): """Module status returns list of modules with counts.""" # Mock db.execute for each module's COUNT query count_result = MagicMock() count_result.fetchone.return_value = (5,) mock_db.execute.return_value = count_result resp = client.get("/dashboard/module-status") assert resp.status_code == 200 data = resp.json() assert "modules" in data assert data["total"] > 0 assert all(m["count"] == 5 for m in data["modules"]) def test_module_status_handles_missing_tables(self): """Module status handles missing tables gracefully.""" mock_db.execute.side_effect = Exception("relation does not exist") resp = client.get("/dashboard/module-status") assert resp.status_code == 200 data = resp.json() # All modules should have count=0 and status=not_started assert all(m["count"] == 0 for m in data["modules"]) assert all(m["status"] == "not_started" for m in data["modules"]) mock_db.execute.side_effect = None # reset class TestNextActions: def test_next_actions_returns_sorted(self): """Next actions returns controls sorted by urgency.""" overdue = datetime.utcnow() - timedelta(days=30) with patch("compliance.api.dashboard_routes.ControlRepository") as MockCtrlRepo: instance = MockCtrlRepo.return_value instance.get_all.return_value = [ MockControl(id="c1", status_val="planned", category="legal", next_review_at=overdue), MockControl(id="c2", status_val="partial", category="best_practice"), MockControl(id="c3", status_val="pass"), # excluded ] resp = client.get("/dashboard/next-actions?limit=5") assert resp.status_code == 200 data = resp.json() assert len(data["actions"]) == 2 # c1 should be first (higher urgency due to legal + overdue) assert data["actions"][0]["control_id"] == "CTRL-001" class TestScoreSnapshot: def test_create_snapshot(self): """Creating a snapshot saves current score.""" with patch("compliance.api.dashboard_routes.ControlRepository") as MockCtrlRepo, \ patch("compliance.api.dashboard_routes.EvidenceRepository") as MockEvRepo, \ patch("compliance.api.dashboard_routes.RiskRepository") as MockRiskRepo: MockCtrlRepo.return_value.get_statistics.return_value = { "total": 20, "pass": 12, "partial": 5, "by_status": {} } MockEvRepo.return_value.get_statistics.return_value = { "total": 10, "by_status": {"valid": 8} } MockRiskRepo.return_value.get_all.return_value = [ MockRisk("high"), MockRisk("critical"), MockRisk("low") ] snap_row = make_snapshot_row() mock_db.execute.return_value.fetchone.return_value = snap_row resp = client.post("/dashboard/snapshot") assert resp.status_code == 200 data = resp.json() assert "score" in data def test_score_history(self): """Score history returns snapshots.""" rows = [make_snapshot_row({"snapshot_date": date(2026, 3, i)}) for i in range(1, 4)] mock_db.execute.return_value.fetchall.return_value = rows resp = client.get("/dashboard/score-history?months=3") assert resp.status_code == 200 data = resp.json() assert data["total"] == 3 assert len(data["snapshots"]) == 3