Files
ss-tools/backend/tests/services/test_report_normalizer.py

425 lines
20 KiB
Python

# #region Test.ReportNormalizer [C:3] [TYPE Module] [SEMANTICS test,report,normalizer,task]
# @BRIEF Tests for services/reports/normalizer.py — status_to_report_status, build_summary, extract_error_context, normalize_task_report.
# @RELATION BINDS_TO -> [Services.Normalizer]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
from datetime import UTC, datetime, timedelta
from unittest.mock import MagicMock, patch
import pytest
# #region helper factories [C:1] [TYPE Function]
# @BRIEF Helper factory for creating mock tasks.
def _make_task(**overrides):
from src.core.task_manager.models import Task, TaskStatus, LogEntry
defaults = {
"id": "task-1",
"plugin_id": "superset-migration",
"status": TaskStatus.SUCCESS,
"params": {},
"result": None,
"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)
# #endregion helper factories
# #region Test.ReportNormalizer.TestStatusToReportStatus [C:2] [TYPE Function]
# @BRIEF Test status_to_report_status mapping.
class TestStatusToReportStatus:
def test_success_maps_to_success(self):
from src.services.reports.normalizer import status_to_report_status
from src.core.task_manager.models import TaskStatus
from src.models.report import ReportStatus
assert status_to_report_status(TaskStatus.SUCCESS) == ReportStatus.SUCCESS
def test_failed_maps_to_failed(self):
from src.services.reports.normalizer import status_to_report_status
from src.core.task_manager.models import TaskStatus
from src.models.report import ReportStatus
assert status_to_report_status(TaskStatus.FAILED) == ReportStatus.FAILED
def test_pending_maps_to_in_progress(self):
from src.services.reports.normalizer import status_to_report_status
from src.core.task_manager.models import TaskStatus
from src.models.report import ReportStatus
for status in [TaskStatus.PENDING, TaskStatus.RUNNING, TaskStatus.AWAITING_INPUT, TaskStatus.AWAITING_MAPPING]:
assert status_to_report_status(status) == ReportStatus.IN_PROGRESS
def test_unknown_string_maps_to_partial(self):
from src.services.reports.normalizer import status_to_report_status
from src.models.report import ReportStatus
assert status_to_report_status("UNKNOWN_STATUS") == ReportStatus.PARTIAL
# #endregion Test.ReportNormalizer.TestStatusToReportStatus
# #region Test.ReportNormalizer.TestBuildSummary [C:2] [TYPE Function]
# @BRIEF Test build_summary extracts summary from task result.
class TestBuildSummary:
def test_uses_result_summary(self):
from src.services.reports.normalizer import build_summary
from src.models.report import ReportStatus
from src.core.task_manager.models import Task, TaskStatus
task = _make_task(result={"summary": "Migration of 5 dashboards completed"})
summary = build_summary(task, ReportStatus.SUCCESS)
assert summary == "Migration of 5 dashboards completed"
def test_uses_result_message(self):
from src.services.reports.normalizer import build_summary
from src.models.report import ReportStatus
task = _make_task(result={"message": "All dashboards migrated"})
summary = build_summary(task, ReportStatus.SUCCESS)
assert summary == "All dashboards migrated"
def test_fallback_to_status_based_summary(self):
from src.services.reports.normalizer import build_summary
from src.models.report import ReportStatus
task = _make_task(result=None)
assert build_summary(task, ReportStatus.SUCCESS) == "Task completed successfully"
assert build_summary(task, ReportStatus.FAILED) == "Task failed"
assert build_summary(task, ReportStatus.IN_PROGRESS) == "Task is in progress"
assert build_summary(task, ReportStatus.PARTIAL) == "Task completed with partial data"
def test_prefers_summary_over_message(self):
from src.services.reports.normalizer import build_summary
from src.models.report import ReportStatus
task = _make_task(result={"summary": "Summary text", "message": "Message text"})
assert build_summary(task, ReportStatus.SUCCESS) == "Summary text"
def test_empty_summary_skips(self):
from src.services.reports.normalizer import build_summary
from src.models.report import ReportStatus
task = _make_task(result={"summary": "", "message": "Actual message"})
assert build_summary(task, ReportStatus.SUCCESS) == "Actual message"
# #endregion Test.ReportNormalizer.TestBuildSummary
# #region Test.ReportNormalizer.TestExtractErrorContext [C:2] [TYPE Function]
# @BRIEF Test extract_error_context for failed/partial reports.
class TestExtractErrorContext:
def test_returns_none_for_success(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task()
assert extract_error_context(task, ReportStatus.SUCCESS) is None
def test_returns_none_for_in_progress(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task()
assert extract_error_context(task, ReportStatus.IN_PROGRESS) is None
def test_extracts_from_error_dict(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(result={"error": {"message": "Connection failed", "code": "ERR_CONNECT", "next_actions": ["Check network", "Retry"]}})
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "Connection failed"
assert ctx.code == "ERR_CONNECT"
assert ctx.next_actions == ["Check network", "Retry"]
def test_falls_back_to_error_message_string(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(result={"error_message": "Something went wrong"})
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "Something went wrong"
def test_falls_back_to_logs(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
from src.core.task_manager.models import LogEntry
task = _make_task(
result={"other": "data"},
logs=[LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="Critical failure in module X", error="Critical failure in module X")],
)
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "Critical failure in module X"
def test_default_message_not_provided(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(result={"unrelated": "data"})
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "Not provided"
def test_default_next_actions(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(result={"error": {"message": "Error without actions"}})
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.next_actions == ["Review task diagnostics", "Retry the operation"]
def test_works_for_partial_status(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(result={"error": {"message": "Partial failure"}})
ctx = extract_error_context(task, ReportStatus.PARTIAL)
assert ctx is not None
assert ctx.message == "Partial failure"
# #endregion Test.ReportNormalizer.TestExtractErrorContext
# #region Test.ReportNormalizer.TestNormalizeTaskReport [C:2] [TYPE Function]
# @BRIEF Test normalize_task_report produces correct TaskReport.
class TestNormalizeTaskReport:
def test_returns_task_report(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import TaskReport, ReportStatus, TaskType
task = _make_task()
report = normalize_task_report(task)
assert isinstance(report, TaskReport)
assert report.report_id == task.id
assert report.task_type == TaskType.MIGRATION
assert report.status == ReportStatus.SUCCESS
assert report.summary == "Task completed successfully"
def test_includes_profile_details(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task()
report = normalize_task_report(task)
assert report.details is not None
assert "profile" in report.details
assert report.details["profile"]["display_label"] == "Migration"
def test_includes_source_ref_from_params(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task(params={"environment_id": "env-1", "dashboard_id": "dash-42"})
report = normalize_task_report(task)
assert report.source_ref is not None
assert report.source_ref["environment_id"] == "env-1"
assert report.source_ref["dashboard_id"] == "dash-42"
def test_includes_error_context_for_failed(self):
from src.services.reports.normalizer import normalize_task_report
from src.core.task_manager.models import TaskStatus
task = _make_task(
status=TaskStatus.FAILED,
result={"error": {"message": "Deployment failed", "code": "ERR_DEPLOY"}},
)
report = normalize_task_report(task)
assert report.error_context is not None
assert report.error_context.message == "Deployment failed"
def test_unknown_plugin_type(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import TaskType
task = _make_task(plugin_id="unknown-plugin")
report = normalize_task_report(task)
assert report.task_type == TaskType.UNKNOWN
def test_no_updated_at_uses_started(self):
from src.services.reports.normalizer import normalize_task_report
from datetime import UTC, datetime
task = _make_task(finished_at=None)
report = normalize_task_report(task)
assert report.updated_at == task.started_at
def test_no_dates_uses_current_time(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task(started_at=None, finished_at=None)
report = normalize_task_report(task)
assert report.updated_at is not None
def test_source_ref_none_when_no_params(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task(params={})
report = normalize_task_report(task)
assert report.source_ref is None
def test_none_result_becomes_note(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task(result=None)
report = normalize_task_report(task)
assert report.details is not None
assert report.details["result"] == {"note": "Not provided"}
def test_dict_result_included(self):
from src.services.reports.normalizer import normalize_task_report
task = _make_task(result={"migrated": 5, "errors": 0})
report = normalize_task_report(task)
assert report.details is not None
assert report.details["result"] == {"migrated": 5, "errors": 0}
def test_include_result_false_omits_result(self):
"""List projection (include_result=False) never embeds task.result."""
from src.services.reports.normalizer import normalize_task_report
task = _make_task(result={"migrated": 5, "errors": 0})
report = normalize_task_report(task, include_result=False)
assert report.details is not None
assert "result" not in report.details
assert report.details["profile"]["display_label"] == "Migration"
# #endregion Test.ReportNormalizer.TestNormalizeTaskReport
# #region Test.ReportNormalizer.TestExtractErrorContextLogFallback [C:2] [TYPE Function]
# @BRIEF Test extract_error_context log-scan fallback (reversed logs, case-insensitive levels).
class TestExtractErrorContextLogFallback:
def test_no_matching_error_log_uses_default(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
from src.core.task_manager.models import LogEntry
task = _make_task(
result={"other": "data"},
logs=[LogEntry(level="INFO", message="informational only")],
)
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "Not provided"
def test_error_log_level_is_case_insensitive(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
from src.core.task_manager.models import LogEntry
task = _make_task(
result={"other": "data"},
logs=[LogEntry(level="Error", trace_id="trace", src="plugin", marker="EXPLORE", intent="cased error", error="cased error")],
)
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "cased error"
def test_last_error_log_wins_in_reverse_order(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
from src.core.task_manager.models import LogEntry
task = _make_task(
result={"other": "data"},
logs=[
LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="first error", error="first error"),
LogEntry(level="INFO", trace_id="trace", src="plugin", marker="REASON", intent="noise"),
LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="last error", error="last error"),
],
)
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "last error"
def test_blank_error_message_falls_through_to_logs(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
from src.core.task_manager.models import LogEntry
task = _make_task(
result={"error": {"message": "", "code": "E1"}},
logs=[LogEntry(level="ERROR", trace_id="trace", src="plugin", marker="EXPLORE", intent="log fallback hit", error="log fallback hit")],
)
ctx = extract_error_context(task, ReportStatus.FAILED)
assert ctx is not None
assert ctx.message == "log fallback hit"
assert ctx.code == "E1"
def test_error_object_actions_strip_blank_entries(self):
from src.services.reports.normalizer import extract_error_context
from src.models.report import ReportStatus
task = _make_task(
result={"error": {"message": "m", "next_actions": [" ", "Fix it", ""]}}
)
ctx = extract_error_context(task, ReportStatus.PARTIAL)
assert ctx is not None
assert ctx.next_actions == ["Fix it"]
# #endregion Test.ReportNormalizer.TestExtractErrorContextLogFallback
# #region Test.ReportNormalizer.TestNormalizeTaskReportLlmValidation [C:2] [TYPE Function]
# @BRIEF Test llm_dashboard_validation success-with-problems override to FAILED/PARTIAL.
class TestNormalizeTaskReportLlmValidation:
def _llm_task(self, **result_overrides):
from src.core.task_manager.models import TaskStatus
result = {"fail_count": 0, "warn_count": 0, "status": "ok"}
result.update(result_overrides)
return _make_task(
plugin_id="llm_dashboard_validation",
status=TaskStatus.SUCCESS,
result=result,
)
def test_fail_count_downgrades_to_failed(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(fail_count=2))
assert report.status == ReportStatus.FAILED
def test_raw_status_fail_downgrades_to_failed(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(status="FAIL"))
assert report.status == ReportStatus.FAILED
def test_warn_count_downgrades_to_partial(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(warn_count=3))
assert report.status == ReportStatus.PARTIAL
def test_raw_status_warn_downgrades_to_partial(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(status="WARN"))
assert report.status == ReportStatus.PARTIAL
def test_benign_result_stays_success(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task())
assert report.status == ReportStatus.SUCCESS
def test_non_int_counts_stay_success(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(fail_count="many", warn_count="many"))
assert report.status == ReportStatus.SUCCESS
def test_none_result_stays_success(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
from src.core.task_manager.models import TaskStatus
task = _make_task(
plugin_id="llm_dashboard_validation",
status=TaskStatus.SUCCESS,
result=None,
)
report = normalize_task_report(task)
assert report.status == ReportStatus.SUCCESS
def test_fail_count_takes_priority_over_warn_count(self):
from src.services.reports.normalizer import normalize_task_report
from src.models.report import ReportStatus
report = normalize_task_report(self._llm_task(fail_count=1, warn_count=5))
assert report.status == ReportStatus.FAILED
# #endregion Test.ReportNormalizer.TestNormalizeTaskReportLlmValidation
# #region Test.ReportNormalizer.TestNormalizeTaskReportSourceRef [C:2] [TYPE Function]
# @BRIEF Test normalize_task_report source_ref when params is not a dict.
class TestNormalizeTaskReportSourceRef:
def test_params_not_dict_yields_none_source_ref(self):
from src.services.reports.normalizer import normalize_task_report
from src.core.task_manager.models import Task, TaskStatus
task = Task.model_construct(
id="task-1",
plugin_id="superset-migration",
status=TaskStatus.SUCCESS,
params=None, # bypasses Pydantic validation via model_construct
result=None,
started_at=datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC),
finished_at=datetime(2024, 1, 1, 12, 30, 0, tzinfo=UTC),
logs=[],
)
report = normalize_task_report(task)
assert report.source_ref is None
assert report.report_id == "task-1"
# #endregion Test.ReportNormalizer.TestNormalizeTaskReportSourceRef
# #endregion Test.ReportNormalizer