# #region Test.ResourceService [C:3] [TYPE Module] [SEMANTICS test,resource,git,task,dashboard,dataset] # # @BRIEF Tests for resource_service.py — dashboard/dataset enrichment with git status and task status. # @RELATION BINDS_TO -> [ResourceServiceModule] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock from datetime import UTC, datetime import pytest # ── Autouse fixture: prevent GitService from touching real filesystem ── # When run in the full suite, a prior test writes global config to the DB # (AppConfigRecord with storage.root_path), causing ResourceService.__init__ # → GitService.__init__ → _resolve_base_path → _ensure_base_path_exists # → mkdir /app/storage/repositories → PermissionError. # This fixture patches GitService so every test gets an isolated Mock. # See ADR-0013 and tests/api/ contamination issue for context. @pytest.fixture(autouse=True) def _patch_git_service(monkeypatch: pytest.MonkeyPatch): """Replace GitService with MagicMock to prevent filesystem access.""" import src.services.resource_service as rs_mod original = rs_mod.GitService rs_mod.GitService = MagicMock # type: ignore[misc] yield rs_mod.GitService = original def make_task(**overrides): """Create a mock Task object with configurable attributes.""" task = MagicMock() defaults = { "id": "task-1", "plugin_id": "llm_dashboard_validation", "status": "COMPLETED", "params": {"dashboard_id": "42", "environment_id": "env-1"}, "result": {"status": "PASS"}, "started_at": datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC), "finished_at": datetime(2026, 1, 1, 12, 5, 0, tzinfo=UTC), "created_at": datetime(2026, 1, 1, 11, 0, 0, tzinfo=UTC), } for k, v in {**defaults, **overrides}.items(): setattr(task, k, v) return task class TestNormalizeTaskStatus: """_normalize_task_status — uppercase, strip enum prefix.""" def test_none_returns_empty(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_task_status(None) == "" def test_plain_string(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_task_status("completed") == "COMPLETED" def test_enum_value(self): from src.services.resource_service import ResourceService svc = ResourceService() mock_enum = MagicMock() mock_enum.value = "RUNNING" assert svc._normalize_task_status(mock_enum) == "RUNNING" def test_with_enum_prefix(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_task_status("TaskStatus.RUNNING") == "RUNNING" class TestNormalizeValidationStatus: """_normalize_validation_status — PASS/FAIL/WARN/UNKNOWN.""" def test_none_returns_none(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status(None) is None def test_pass(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status("PASS") == "PASS" def test_fail(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status("FAIL") == "FAIL" def test_warn(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status("warn") == "WARN" def test_unknown(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status("UNKNOWN") == "UNKNOWN" def test_other_defaults_unknown(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._normalize_validation_status("invalid_status") == "UNKNOWN" class TestNormalizeDatetime: """_normalize_datetime_for_compare — UTC-aware comparison.""" def test_naive_converted_to_utc(self): from src.services.resource_service import ResourceService svc = ResourceService() dt = datetime(2026, 1, 1, 12, 0, 0) result = svc._normalize_datetime_for_compare(dt) assert result.tzinfo is not None assert result == dt.replace(tzinfo=UTC) def test_aware_converted_to_utc(self): from src.services.resource_service import ResourceService svc = ResourceService() from datetime import timezone dt = datetime(2026, 1, 1, 12, 0, 0, tzinfo=timezone.utc) result = svc._normalize_datetime_for_compare(dt) assert result == dt def test_non_datetime_returns_min(self): from src.services.resource_service import ResourceService svc = ResourceService() result = svc._normalize_datetime_for_compare("not-a-date") assert result == datetime.min.replace(tzinfo=UTC) class TestExtractFromTask: """_extract_resource_name_from_task and _extract_resource_type_from_task.""" def test_name_from_params(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"resource_name": "Sales Dashboard"}) assert svc._extract_resource_name_from_task(task) == "Sales Dashboard" def test_name_fallback(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={}) task.id = "task-99" assert svc._extract_resource_name_from_task(task) == "Task task-99" def test_type_from_params(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"resource_type": "dashboard"}) assert svc._extract_resource_type_from_task(task) == "dashboard" def test_type_default(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={}) assert svc._extract_resource_type_from_task(task) == "unknown" class TestGetLastTaskForResource: """_get_last_task_for_resource — most recent task by created_at.""" def test_no_tasks_returns_none(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._get_last_task_for_resource("dataset-42", None) is None def test_empty_list_returns_none(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._get_last_task_for_resource("dataset-42", []) is None def test_no_matching_tasks(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"resource_id": "dataset-other"}) assert svc._get_last_task_for_resource("dataset-42", [task]) is None def test_returns_latest_task(self): from src.services.resource_service import ResourceService svc = ResourceService() old = make_task( id="task-old", params={"resource_id": "dataset-42"}, created_at=datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC), ) new = make_task( id="task-new", params={"resource_id": "dataset-42"}, created_at=datetime(2026, 1, 2, 10, 0, 0, tzinfo=UTC), ) result = svc._get_last_task_for_resource("dataset-42", [old, new]) assert result is not None assert result["task_id"] == "task-new" def test_task_without_created_at_uses_min(self): from src.services.resource_service import ResourceService svc = ResourceService() task_nodate = make_task( id="task-nodate", params={"resource_id": "dataset-42"}, ) del task_nodate.created_at # remove created_at task_nodate.started_at = None task_nodate.finished_at = None task_dated = make_task( id="task-dated", params={"resource_id": "dataset-42"}, created_at=datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC), ) result = svc._get_last_task_for_resource("dataset-42", [task_nodate, task_dated]) assert result["task_id"] == "task-dated" class TestGetActivitySummary: """get_activity_summary — active count and recent tasks.""" def test_empty_tasks(self): from src.services.resource_service import ResourceService svc = ResourceService() result = svc.get_activity_summary([]) assert result["active_count"] == 0 assert result["recent_tasks"] == [] def test_active_task_count(self): from src.services.resource_service import ResourceService svc = ResourceService() running = make_task(id="t1", params={}, status="RUNNING") waiting = make_task(id="t2", params={}, status="WAITING_INPUT") completed = make_task(id="t3", params={}, status="COMPLETED") result = svc.get_activity_summary([running, waiting, completed]) assert result["active_count"] == 2 def test_recent_tasks_limit(self): from src.services.resource_service import ResourceService svc = ResourceService() tasks = [ make_task(id=f"t{i}", params={}, status="COMPLETED", created_at=datetime(2026, 1, i + 1, 0, 0, 0, tzinfo=UTC)) for i in range(10) ] result = svc.get_activity_summary(tasks) assert len(result["recent_tasks"]) == 5 def test_recent_tasks_sorted(self): from src.services.resource_service import ResourceService svc = ResourceService() old = make_task( id="t-old", params={"resource_name": "Old", "resource_type": "dashboard"}, status="COMPLETED", created_at=datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC), ) new = make_task( id="t-new", params={"resource_name": "New", "resource_type": "chart"}, status="FAILED", created_at=datetime(2026, 1, 5, 0, 0, 0, tzinfo=UTC), ) result = svc.get_activity_summary([old, new]) assert result["recent_tasks"][0]["task_id"] == "t-new" class TestGetLastLlmTaskForDashboard: """_get_last_llm_task_for_dashboard — filter by plugin_id, dashboard_id, env.""" def test_no_tasks_returns_none(self): from src.services.resource_service import ResourceService svc = ResourceService() assert svc._get_last_llm_task_for_dashboard(42, "env-1", None) is None def test_wrong_plugin_skipped(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(plugin_id="other_plugin", params={"dashboard_id": "42"}) assert svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) is None def test_wrong_dashboard_skipped(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"dashboard_id": "99"}) assert svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) is None def test_wrong_env_skipped(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"dashboard_id": "42", "environment_id": "env-2"}) assert svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) is None def test_matching_task_returns_summary(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"dashboard_id": "42", "environment_id": "env-1"}) result = svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) assert result is not None assert result["task_id"] == "task-1" assert result["status"] == "COMPLETED" assert result["validation_status"] == "PASS" def test_prefers_latest(self): from src.services.resource_service import ResourceService svc = ResourceService() old = make_task( id="old", params={"dashboard_id": "42", "environment_id": "env-1"}, started_at=datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC), ) new = make_task( id="new", params={"dashboard_id": "42", "environment_id": "env-1"}, started_at=datetime(2026, 1, 2, 10, 0, 0, tzinfo=UTC), ) result = svc._get_last_llm_task_for_dashboard(42, "env-1", [old, new]) assert result["task_id"] == "new" def test_null_result_handled(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(result=None, params={"dashboard_id": "42", "environment_id": "env-1"}) result = svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) assert result is not None assert result["validation_status"] is None def test_env_id_none_matches_any(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"dashboard_id": "42"}) result = svc._get_last_llm_task_for_dashboard(42, None, [task]) assert result is not None def test_env_match_with_env_key_fallback(self): from src.services.resource_service import ResourceService svc = ResourceService() task = make_task(params={"dashboard_id": "42", "env": "env-1"}) result = svc._get_last_llm_task_for_dashboard(42, "env-1", [task]) assert result is not None class TestGetLastLlmTaskValidationStatusFallback: """_get_last_llm_task_for_dashboard — validation status fallback logic.""" def test_decisive_task_used_when_latest_unknown(self): from src.services.resource_service import ResourceService svc = ResourceService() unknown = make_task( id="latest", params={"dashboard_id": "42", "environment_id": "env-1"}, result={"status": "UNKNOWN"}, started_at=datetime(2026, 1, 2, 10, 0, 0, tzinfo=UTC), ) decisive = make_task( id="decisive", params={"dashboard_id": "42", "environment_id": "env-1"}, result={"status": "FAIL"}, started_at=datetime(2026, 1, 1, 10, 0, 0, tzinfo=UTC), ) result = svc._get_last_llm_task_for_dashboard(42, "env-1", [unknown, decisive]) assert result["task_id"] == "latest" assert result["validation_status"] == "FAIL" class TestGetGitStatusForDashboard: """_get_git_status_for_dashboard — git repo status enrichment.""" @pytest.mark.asyncio async def test_no_repo(self): from src.services.resource_service import ResourceService from fastapi import HTTPException svc = ResourceService() svc.git_service = MagicMock() svc.git_service.get_repo = AsyncMock(side_effect=HTTPException(status_code=404)) result = await svc._get_git_status_for_dashboard(42) assert result["sync_status"] == "NO_REPO" assert result["has_repo"] is False @pytest.mark.asyncio async def test_repo_error(self): from src.services.resource_service import ResourceService svc = ResourceService() svc.git_service = MagicMock() svc.git_service.get_repo = AsyncMock(side_effect=Exception("Git error")) result = await svc._get_git_status_for_dashboard(42) assert result["sync_status"] == "NO_REPO" @pytest.mark.asyncio async def test_git_operations_error(self): from src.services.resource_service import ResourceService svc = ResourceService() repo = MagicMock() repo.active_branch.name = "main" repo.is_dirty.side_effect = Exception("Dirty check failed") svc.git_service = MagicMock() svc.git_service.get_repo = AsyncMock(return_value=repo) result = await svc._get_git_status_for_dashboard(42) assert result["sync_status"] == "ERROR" assert result["has_repo"] is True @pytest.mark.asyncio async def test_clean_repo(self): from src.services.resource_service import ResourceService svc = ResourceService() repo = MagicMock() repo.active_branch.name = "main" repo.is_dirty.return_value = False svc.git_service = MagicMock() svc.git_service.get_repo = AsyncMock(return_value=repo) repo.iter_commits.return_value = [] result = await svc._get_git_status_for_dashboard(42) assert result["sync_status"] == "OK" assert result["has_repo"] is True assert result["branch"] == "main" @pytest.mark.asyncio async def test_dirty_repo(self): from src.services.resource_service import ResourceService svc = ResourceService() repo = MagicMock() repo.active_branch.name = "main" repo.is_dirty.return_value = True svc.git_service = MagicMock() svc.git_service.get_repo = AsyncMock(return_value=repo) repo.iter_commits.return_value = [] result = await svc._get_git_status_for_dashboard(42) assert result["sync_status"] == "DIFF" assert result["has_changes_for_commit"] is True @patch("src.services.resource_service.get_superset_client") class TestGetDashboardsWithStatus: """get_dashboards_with_status — enrich dashboards with git/task status.""" @pytest.fixture def service(self): from src.services.resource_service import ResourceService svc = ResourceService() svc._get_git_status_for_dashboard = AsyncMock(return_value={"sync_status": "OK"}) svc._get_last_llm_task_for_dashboard = MagicMock(return_value={"status": "PASS"}) return svc @pytest.mark.asyncio async def test_enriches_dashboards(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary.return_value = [ {"id": 42, "dashboard_title": "Sales"}, {"id": 99, "dashboard_title": "Marketing"}, ] mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_with_status(env, tasks=[], include_git_status=True) assert len(result) == 2 assert result[0]["git_status"] == {"sync_status": "OK"} assert result[0]["last_task"] == {"status": "PASS"} @pytest.mark.asyncio async def test_include_git_status_false(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary.return_value = [{"id": 42}] mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_with_status(env, tasks=[], include_git_status=False) assert result[0]["git_status"] is None @patch("src.services.resource_service.get_superset_client") class TestGetDashboardsPageWithStatus: """get_dashboards_page_with_status — paginated enrichment.""" @pytest.fixture def service(self): from src.services.resource_service import ResourceService svc = ResourceService() svc._get_git_status_for_dashboard = AsyncMock(return_value={"sync_status": "OK"}) svc._get_last_llm_task_for_dashboard = MagicMock(return_value={"status": "PASS"}) return svc @pytest.mark.asyncio async def test_paginated_result(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary_page.return_value = (50, [ {"id": 1, "dashboard_title": "A"}, {"id": 2, "dashboard_title": "B"}, ]) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_page_with_status( env, tasks=[], page=1, page_size=10 ) assert result["total"] == 50 assert len(result["dashboards"]) == 2 assert result["total_pages"] == 5 @pytest.mark.asyncio async def test_zero_total_pages(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary_page.return_value = (0, []) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_page_with_status( env, tasks=[], page=1, page_size=10 ) assert result["total"] == 0 assert result["total_pages"] == 1 assert result["dashboards"] == [] @pytest.mark.asyncio async def test_with_search(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary_page.return_value = (1, [{"id": 42}]) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_page_with_status( env, tasks=[], page=1, page_size=20, search="Sales", ) mock_client.get_dashboards_summary_page.assert_called_with( page=1, page_size=20, search="Sales", require_slug=False, ) assert len(result["dashboards"]) == 1 @pytest.mark.asyncio async def test_include_git_status_false(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_dashboards_summary_page.return_value = (1, [{"id": 1}]) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_dashboards_page_with_status( env, page=1, page_size=10, include_git_status=False, ) assert result["dashboards"][0]["git_status"] is None @patch("src.services.resource_service.get_superset_client") class TestGetDatasetsWithStatus: """get_datasets_with_status — enrich datasets with task status and linked counts.""" @pytest.fixture def service(self): from src.services.resource_service import ResourceService svc = ResourceService() svc._get_last_task_for_resource = MagicMock(return_value={"status": "COMPLETED"}) return svc @pytest.mark.asyncio async def test_enriches_datasets(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_datasets_summary.return_value = [ {"id": 1, "table_name": "users"}, {"id": 2, "table_name": "orders"}, ] mock_client.get_dataset_linked_dashboard_count = AsyncMock(return_value=3) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_datasets_with_status(env, tasks=[]) assert len(result) == 2 assert result[0]["last_task"] == {"status": "COMPLETED"} assert result[0]["linked_dashboard_count"] == 3 assert result[1]["linked_dashboard_count"] == 3 @pytest.mark.asyncio async def test_linked_count_failure_returns_zero(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_datasets_summary.return_value = [{"id": 1, "table_name": "users"}] mock_client.get_dataset_linked_dashboard_count = AsyncMock( side_effect=TimeoutError("Superset timeout") ) mock_client_fn.return_value = mock_client env = MagicMock() env.id = "env-1" result = await service.get_datasets_with_status(env, tasks=[]) assert result[0]["linked_dashboard_count"] == 0 @pytest.mark.asyncio async def test_no_tasks_returns_none_last_task(self, mock_client_fn, service): mock_client = AsyncMock() mock_client.get_datasets_summary.return_value = [{"id": 1, "table_name": "users"}] mock_client.get_dataset_linked_dashboard_count = AsyncMock(return_value=0) mock_client_fn.return_value = mock_client service._get_last_task_for_resource.return_value = None env = MagicMock() env.id = "env-1" result = await service.get_datasets_with_status(env, tasks=None) assert result[0]["last_task"] is None # #endregion Test.ResourceService