"""Tests for heatmap_service — pure helpers, error paths, and dispatch.""" import pytest from unittest.mock import MagicMock, patch from app.services.heatmap_service import ( _score_to_color, _build_layer_skeleton, _parse_csv, _format_tactic, build_navigator_export, build_threat_actor_layer, build_campaign_layer, SUPPORTED_LAYER_TYPES, ATTACK_VERSION, NAVIGATOR_VERSION, LAYER_VERSION, DOMAIN, ) from app.domain.errors import BusinessRuleViolation, EntityNotFoundError # ── Pure helpers ────────────────────────────────────────────────────── class TestScoreToColor: def test_zero_returns_grey(self): assert _score_to_color(0) == "#d3d3d3" def test_negative_returns_grey(self): assert _score_to_color(-10) == "#d3d3d3" def test_low_returns_red(self): assert _score_to_color(25) == "#ff6666" def test_medium_returns_orange(self): assert _score_to_color(50) == "#ff9933" def test_high_returns_yellow(self): assert _score_to_color(75) == "#ffff66" def test_max_returns_green(self): assert _score_to_color(100) == "#66ff66" class TestBuildLayerSkeleton: def test_has_required_keys(self): layer = _build_layer_skeleton("Test Layer", "A description") assert layer["name"] == "Test Layer" assert layer["description"] == "A description" assert layer["domain"] == DOMAIN assert layer["techniques"] == [] assert layer["versions"]["attack"] == ATTACK_VERSION assert layer["versions"]["navigator"] == NAVIGATOR_VERSION assert layer["versions"]["layer"] == LAYER_VERSION def test_default_gradient(self): layer = _build_layer_skeleton("X", "Y") assert layer["gradient"]["minValue"] == 0 assert layer["gradient"]["maxValue"] == 100 assert len(layer["gradient"]["colors"]) == 3 def test_custom_gradient(self): layer = _build_layer_skeleton("X", "Y", gradient_colors=["#000", "#fff"]) assert layer["gradient"]["colors"] == ["#000", "#fff"] class TestParseCsv: def test_none_returns_none(self): assert _parse_csv(None) is None def test_empty_string_returns_none(self): assert _parse_csv("") is None def test_single_value(self): assert _parse_csv("windows") == ["windows"] def test_multiple_values_with_spaces(self): assert _parse_csv("windows, linux, macos") == ["windows", "linux", "macos"] def test_empty_elements_filtered(self): assert _parse_csv("a,,b") == ["a", "b"] class TestFormatTactic: def test_none_returns_empty(self): assert _format_tactic(None) == "" def test_empty_returns_empty(self): assert _format_tactic("") == "" def test_lowercases(self): assert _format_tactic("Initial Access") == "initial access" def test_comma_separated_takes_first(self): assert _format_tactic("Execution, Persistence") == "execution" # ── build_navigator_export dispatch ─────────────────────────────────── def _mock_db(): return MagicMock() class TestBuildNavigatorExport: def test_dispatches_coverage(self): from app.services.heatmap_service import LAYER_REGISTRY mock_build = MagicMock(return_value={"name": "coverage"}) orig = LAYER_REGISTRY._simple["coverage"] LAYER_REGISTRY._simple["coverage"] = mock_build try: result = build_navigator_export(_mock_db(), "coverage") assert result["name"] == "coverage" mock_build.assert_called_once() finally: LAYER_REGISTRY._simple["coverage"] = orig def test_dispatches_detection_rules(self): from app.services.heatmap_service import LAYER_REGISTRY mock_build = MagicMock(return_value={"name": "rules"}) orig = LAYER_REGISTRY._simple["detection-rules"] LAYER_REGISTRY._simple["detection-rules"] = mock_build try: result = build_navigator_export(_mock_db(), "detection-rules") assert result["name"] == "rules" mock_build.assert_called_once() finally: LAYER_REGISTRY._simple["detection-rules"] = orig def test_dispatches_threat_actor_with_id(self): from app.services.heatmap_service import LAYER_REGISTRY mock_build = MagicMock(return_value={"name": "actor"}) orig = LAYER_REGISTRY._with_id["threat-actor"] LAYER_REGISTRY._with_id["threat-actor"] = mock_build try: result = build_navigator_export(_mock_db(), "threat-actor", layer_id="abc") assert result["name"] == "actor" mock_build.assert_called_once() finally: LAYER_REGISTRY._with_id["threat-actor"] = orig def test_dispatches_campaign_with_id(self): from app.services.heatmap_service import LAYER_REGISTRY mock_build = MagicMock(return_value={"name": "campaign"}) orig = LAYER_REGISTRY._with_id["campaign"] LAYER_REGISTRY._with_id["campaign"] = mock_build try: result = build_navigator_export(_mock_db(), "campaign", layer_id="xyz") assert result["name"] == "campaign" mock_build.assert_called_once() finally: LAYER_REGISTRY._with_id["campaign"] = orig def test_unknown_layer_raises(self): with pytest.raises(BusinessRuleViolation, match="Unknown layer type"): build_navigator_export(_mock_db(), "nonexistent") def test_missing_layer_id_for_threat_actor(self): with pytest.raises(BusinessRuleViolation, match="layer_id is required"): build_navigator_export(_mock_db(), "threat-actor") def test_missing_layer_id_for_campaign(self): with pytest.raises(BusinessRuleViolation, match="layer_id is required"): build_navigator_export(_mock_db(), "campaign") def test_supported_layer_types_complete(self): assert SUPPORTED_LAYER_TYPES == { "coverage", "detection-rules", "threat-actor", "campaign", } def test_passes_filter_kwargs(self): from app.services.heatmap_service import LAYER_REGISTRY mock_build = MagicMock(return_value={}) orig = LAYER_REGISTRY._simple["coverage"] LAYER_REGISTRY._simple["coverage"] = mock_build try: build_navigator_export( _mock_db(), "coverage", platforms="windows", tactics="execution", min_score=50, ) _, kwargs = mock_build.call_args assert kwargs["platforms"] == "windows" assert kwargs["tactics"] == "execution" assert kwargs["min_score"] == 50 finally: LAYER_REGISTRY._simple["coverage"] = orig # ── Entity-not-found errors ─────────────────────────────────────────── class TestEntityNotFound: def _db_returning_none(self): db = MagicMock() db.query.return_value.filter.return_value.first.return_value = None return db def test_threat_actor_not_found(self): with pytest.raises(EntityNotFoundError, match="ThreatActor"): build_threat_actor_layer(self._db_returning_none(), "bad-id") def test_campaign_not_found(self): with pytest.raises(EntityNotFoundError, match="Campaign"): build_campaign_layer(self._db_returning_none(), "bad-id")