# #region Test.ReportsService [C:3] [TYPE Module] [SEMANTICS test,report,service,pagination,filter] # @BRIEF Tests for services/reports/report_service.py — ReportsService. # @RELATION BINDS_TO -> [report_service] from pathlib import Path import sys sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) from datetime import UTC, datetime from unittest.mock import AsyncMock, MagicMock, patch import pytest # #region helper factories [C:1] [TYPE Function] def _make_task(**overrides): from src.core.task_manager.models import Task, TaskStatus defaults = { "id": "task-1", "plugin_id": "superset-migration", "status": TaskStatus.SUCCESS, "params": {}, "result": {"summary": "Migration complete"}, "started_at": datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC), "finished_at": datetime(2024, 1, 1, 12, 30, 0, tzinfo=UTC), "logs": [], } defaults.update(overrides) return Task(**defaults) def _make_service(task_manager=None, clean_release_repo=None): from src.services.reports.report_service import ReportsService if task_manager is None: task_manager = MagicMock() task_manager.get_all_tasks.return_value = [] return ReportsService(task_manager, clean_release_repo) # #endregion helper factories # #region test_reports_service_init [C:2] [TYPE Function] # @BRIEF Test ReportsService initialization. class TestReportsServiceInit: def test_stores_task_manager(self): tm = MagicMock() service = _make_service(task_manager=tm) assert service.task_manager is tm def test_stores_clean_release_repo(self): crr = MagicMock() service = _make_service(clean_release_repo=crr) assert service.clean_release_repository is crr def test_clean_release_repo_optional(self): service = _make_service() assert service.clean_release_repository is None # #endregion test_reports_service_init # #region test_load_normalized_reports [C:2] [TYPE Function] # @BRIEF Test _load_normalized_reports. class TestLoadNormalizedReports: def test_returns_empty_list_for_no_tasks(self): tm = MagicMock() tm.get_all_tasks.return_value = [] service = _make_service(task_manager=tm) reports = service._load_normalized_reports() assert reports == [] def test_normalizes_all_tasks(self): tm = MagicMock() task = _make_task() tm.get_all_tasks.return_value = [task] service = _make_service(task_manager=tm) reports = service._load_normalized_reports() assert len(reports) == 1 assert reports[0].report_id == task.id # #endregion test_load_normalized_reports # #region test_to_utc_datetime [C:2] [TYPE Function] # @BRIEF Test _to_utc_datetime. class TestToUtcDatetime: def test_none_returns_none(self): service = _make_service() assert service._to_utc_datetime(None) is None def test_naive_interpreted_as_utc(self): service = _make_service() dt = datetime(2024, 1, 1, 12, 0, 0) result = service._to_utc_datetime(dt) assert result.tzinfo is not None assert result.tzinfo == UTC def test_aware_converted_to_utc(self): service = _make_service() from zoneinfo import ZoneInfo dt = datetime(2024, 1, 1, 15, 0, 0, tzinfo=ZoneInfo("Europe/Moscow")) result = service._to_utc_datetime(dt) assert result.tzinfo == UTC assert result.hour == 12 # Moscow +3 -> UTC # #endregion test_to_utc_datetime # #region test_datetime_sort_key [C:2] [TYPE Function] # @BRIEF Test _datetime_sort_key. class TestDatetimeSortKey: def test_returns_timestamp(self): from src.models.report import TaskReport, ReportStatus, TaskType service = _make_service() report = MagicMock(spec=TaskReport) report.updated_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC) key = service._datetime_sort_key(report) assert key == 1704110400.0 def test_returns_zero_for_none(self): from src.models.report import TaskReport service = _make_service() report = MagicMock(spec=TaskReport) report.updated_at = None key = service._datetime_sort_key(report) assert key == 0.0 # #endregion test_datetime_sort_key # #region test_matches_query [C:2] [TYPE Function] # @BRIEF Test _matches_query. class TestMatchesQuery: def test_no_filters_matches_all(self): from src.models.report import ReportQuery service = _make_service() report = MagicMock() query = ReportQuery() assert service._matches_query(report, query) is True def test_filters_by_task_type(self): from src.models.report import ReportQuery, TaskType service = _make_service() report = MagicMock() report.task_type = TaskType.MIGRATION query = ReportQuery(task_types=[TaskType.MIGRATION]) assert service._matches_query(report, query) is True query = ReportQuery(task_types=[TaskType.BACKUP]) assert service._matches_query(report, query) is False def test_filters_by_status(self): from src.models.report import ReportQuery, ReportStatus service = _make_service() report = MagicMock() report.status = ReportStatus.SUCCESS query = ReportQuery(statuses=[ReportStatus.SUCCESS]) assert service._matches_query(report, query) is True query = ReportQuery(statuses=[ReportStatus.FAILED]) assert service._matches_query(report, query) is False def test_filters_by_time_range(self): from src.models.report import ReportQuery service = _make_service() report = MagicMock() report.updated_at = datetime(2024, 6, 15, 12, 0, 0, tzinfo=UTC) # Time from before -> match query = ReportQuery(time_from=datetime(2024, 1, 1, tzinfo=UTC)) assert service._matches_query(report, query) is True # Time from after -> no match query = ReportQuery(time_from=datetime(2024, 12, 1, tzinfo=UTC)) assert service._matches_query(report, query) is False # Time to before -> no match query = ReportQuery(time_to=datetime(2024, 1, 1, tzinfo=UTC)) assert service._matches_query(report, query) is False def test_filters_by_search(self): from src.models.report import ReportQuery, ReportStatus, TaskType service = _make_service() report = MagicMock() report.summary = "Migration completed" report.task_type = TaskType.MIGRATION report.status = ReportStatus.SUCCESS query = ReportQuery(search="migration") assert service._matches_query(report, query) is True query = ReportQuery(search="backup") assert service._matches_query(report, query) is False # #endregion test_matches_query # #region test_sort_reports [C:2] [TYPE Function] # @BRIEF Test _sort_reports. class TestSortReports: def test_sort_by_updated_at_desc_default(self): from src.models.report import ReportQuery service = _make_service() reports = [ MagicMock(updated_at=datetime(2024, 1, 1, tzinfo=UTC)), MagicMock(updated_at=datetime(2024, 6, 15, tzinfo=UTC)), MagicMock(updated_at=datetime(2024, 3, 1, tzinfo=UTC)), ] query = ReportQuery() sorted_reports = service._sort_reports(reports, query) assert sorted_reports[0].updated_at > sorted_reports[-1].updated_at def test_sort_by_status_asc(self): from src.models.report import ReportQuery, ReportStatus service = _make_service() reports = [ MagicMock(status=ReportStatus.FAILED, updated_at=datetime(2024, 1, 1, tzinfo=UTC)), MagicMock(status=ReportStatus.SUCCESS, updated_at=datetime(2024, 6, 15, tzinfo=UTC)), ] query = ReportQuery(sort_by="status", sort_order="asc") sorted_reports = service._sort_reports(reports, query) assert sorted_reports[0].status.value < sorted_reports[1].status.value def test_sort_by_task_type_desc(self): from src.models.report import ReportQuery, TaskType service = _make_service() reports = [ MagicMock(task_type=TaskType.BACKUP, updated_at=datetime(2024, 1, 1, tzinfo=UTC)), MagicMock(task_type=TaskType.MIGRATION, updated_at=datetime(2024, 6, 15, tzinfo=UTC)), ] query = ReportQuery(sort_by="task_type", sort_order="desc") sorted_reports = service._sort_reports(reports, query) assert sorted_reports[0].task_type.value > sorted_reports[1].task_type.value # #endregion test_sort_reports # #region test_list_reports [C:2] [TYPE Function] # @BRIEF Test list_reports with pagination. class TestListReports: def test_returns_empty_collection(self): from src.models.report import ReportQuery tm = MagicMock() tm.get_all_tasks.return_value = [] service = _make_service(task_manager=tm) result = service.list_reports(ReportQuery()) assert result.total == 0 assert result.items == [] assert result.has_next is False def test_paginates_results(self): from src.models.report import ReportQuery tm = MagicMock() tasks = [_make_task(id=f"task-{i}", plugin_id="test") for i in range(5)] tm.get_all_tasks.return_value = tasks service = _make_service(task_manager=tm) query = ReportQuery(page=1, page_size=2) result = service.list_reports(query) assert result.total == 5 assert len(result.items) == 2 assert result.has_next is True def test_last_page_no_next(self): from src.models.report import ReportQuery tm = MagicMock() tasks = [_make_task(id=f"task-{i}", plugin_id="test") for i in range(3)] tm.get_all_tasks.return_value = tasks service = _make_service(task_manager=tm) query = ReportQuery(page=2, page_size=2) result = service.list_reports(query) assert result.total == 3 assert len(result.items) == 1 assert result.has_next is False def test_filters_applied(self): from src.models.report import ReportQuery, TaskType from src.core.task_manager.models import TaskStatus tm = MagicMock() tasks = [ _make_task(id="task-backup", plugin_id="superset-backup"), _make_task(id="task-migration", plugin_id="superset-migration"), ] tm.get_all_tasks.return_value = tasks service = _make_service(task_manager=tm) query = ReportQuery(task_types=[TaskType.BACKUP]) result = service.list_reports(query) assert result.total == 1 assert result.items[0].task_type == TaskType.BACKUP def test_search_filter(self): from src.models.report import ReportQuery from src.core.task_manager.models import TaskStatus tm = MagicMock() tasks = [ _make_task(id="t1", plugin_id="superset-migration", result={"summary": "Backup completed"}), _make_task(id="t2", plugin_id="superset-backup", result={"summary": "Migration done"}), ] tm.get_all_tasks.return_value = tasks service = _make_service(task_manager=tm) query = ReportQuery(search="backup") result = service.list_reports(query) # Both "Backup" and "backup" summaries match assert result.total >= 1 # #endregion test_list_reports # #region test_get_report_detail [C:2] [TYPE Function] # @BRIEF Test get_report_detail. class TestGetReportDetail: def test_returns_none_for_missing(self): tm = MagicMock() tm.get_all_tasks.return_value = [] service = _make_service(task_manager=tm) result = service.get_report_detail("nonexistent") assert result is None def test_returns_detail_for_existing(self): tm = MagicMock() task = _make_task(id="task-1") tm.get_all_tasks.return_value = [task] service = _make_service(task_manager=tm) result = service.get_report_detail("task-1") assert result is not None assert result.report.report_id == "task-1" assert len(result.timeline) == 2 # started + updated def test_recovery_actions_for_failed(self): from src.core.task_manager.models import TaskStatus tm = MagicMock() task = _make_task(id="task-1", status=TaskStatus.FAILED, result={"error": {"message": "Failed"}}) tm.get_all_tasks.return_value = [task] service = _make_service(task_manager=tm) result = service.get_report_detail("task-1") assert result is not None assert len(result.next_actions) > 0 def test_default_next_actions_for_failed(self): from src.core.task_manager.models import TaskStatus tm = MagicMock() task = _make_task(id="task-1", status=TaskStatus.FAILED, result=None) tm.get_all_tasks.return_value = [task] service = _make_service(task_manager=tm) result = service.get_report_detail("task-1") assert result is not None assert "Review task diagnostics" in result.next_actions def test_clean_release_enriches_detail(self): from src.core.task_manager.models import TaskStatus from src.models.report import TaskType # Create a task that maps to CLEAN_RELEASE type tm = MagicMock() task = _make_task(id="cr-1", plugin_id="clean-release-compliance", result={"run_id": "run-1"}) tm.get_all_tasks.return_value = [task] crr = MagicMock() mock_run = MagicMock() mock_run.id = "run-1" mock_run.candidate_id = "cand-1" mock_run.status = "completed" mock_run.final_status = "approved" mock_run.requested_by = "admin" crr.get_check_run.return_value = mock_run crr.reports = {} service = _make_service(task_manager=tm, clean_release_repo=crr) result = service.get_report_detail("cr-1") assert result is not None assert "clean_release_run" in result.diagnostics def test_clean_release_report_linked(self): tm = MagicMock() task = _make_task(id="cr-2", plugin_id="clean-release-compliance", result={"run_id": "run-2"}) tm.get_all_tasks.return_value = [task] crr = MagicMock() mock_run = MagicMock() mock_run.id = "run-2" mock_run.candidate_id = "cand-2" mock_run.status = "completed" mock_run.final_status = "approved" mock_run.requested_by = "admin" crr.get_check_run.return_value = mock_run mock_report = MagicMock() mock_report.id = "report-1" mock_report.final_status = "compliant" crr.reports = {"rep1": mock_report} # Make the iteration find the report by run_id mock_report.run_id = "run-2" service = _make_service(task_manager=tm, clean_release_repo=crr) result = service.get_report_detail("cr-2") assert result is not None assert "clean_release_report" in result.diagnostics def test_diagnostics_not_provided(self): tm = MagicMock() task = _make_task(id="task-1", result=None) tm.get_all_tasks.return_value = [task] service = _make_service(task_manager=tm) result = service.get_report_detail("task-1") assert result is not None assert result.diagnostics is not None def test_diagnostics_empty_details_uses_fallback(self): """When details is empty/falsy, uses 'Not provided' fallback (line 247).""" from src.models.report import TaskReport, ReportStatus, TaskType from unittest.mock import PropertyMock from datetime import UTC, datetime tm = MagicMock() tm.get_all_tasks.return_value = [] service = _make_service(task_manager=tm) # Mock _load_normalized_reports to return a report with empty details mock_report = MagicMock(spec=TaskReport) mock_report.report_id = "empty-details-task" mock_report.task_type = TaskType.MIGRATION mock_report.status = ReportStatus.SUCCESS mock_report.updated_at = datetime(2024, 1, 1, tzinfo=UTC) mock_report.started_at = None mock_report.error_context = None type(mock_report).details = PropertyMock(return_value={}) with patch.object(service, "_load_normalized_reports", return_value=[mock_report]): result = service.get_report_detail("empty-details-task") assert result is not None assert result.diagnostics == {"note": "Not provided"} def test_default_next_actions_for_failed_without_error_context_actions(self): """When error_context exists but next_actions is empty, uses default (line 290).""" from src.models.report import TaskReport, ReportStatus, TaskType, ErrorContext from datetime import UTC, datetime tm = MagicMock() tm.get_all_tasks.return_value = [] service = _make_service(task_manager=tm) mock_report = MagicMock(spec=TaskReport) mock_report.report_id = "failed-no-actions" mock_report.task_type = TaskType.MIGRATION mock_report.status = ReportStatus.FAILED mock_report.updated_at = datetime(2024, 1, 1, tzinfo=UTC) mock_report.started_at = None mock_report.details = {"result": {}} mock_report.error_context = ErrorContext( message="test error", code=None, next_actions=[], ) with patch.object(service, "_load_normalized_reports", return_value=[mock_report]): result = service.get_report_detail("failed-no-actions") assert result is not None assert "Review diagnostics" in result.next_actions assert "Retry task if applicable" in result.next_actions # #endregion test_get_report_detail # #endregion Test.ReportsService