- ~60 new/extended test files across api, core, plugins, services, schemas: routes, superset clients, task_manager, lineage, git, translate, dashboard-testing, load-testing, migration, llm_analysis, scheduler, ssl - .coveragerc: enable branch coverage; exclude src/__tests__ (test files) and src/scripts (CLI/ops tools) from the denominator - bug fixes found while testing: * settings: PUT /settings/reports registered under duplicated prefix * schemas/lineage: FleetReportDTO missing run_status (route always 500) * dashboard_testing/baseline_inheritance: visual entry read wrong field * superset_client/_databases: logger extra name shadowed LogRecord attr * routes/datasets: _yaml_string_paths recursion without yield from * translate/sql_generator: restore explicit-type timestamp contract * baseline_catalog: remove unreachable dashboard_id fallback - conftest fixes: pytest_plugins to rootdir conftest (pytest 9), test filename collision, TMPDIR-safe integration fixtures
284 lines
12 KiB
Python
284 lines
12 KiB
Python
# #region Test.ValidationTaskService.Branches [C:3] [TYPE Module] [SEMANTICS test,validation,branches,coverage]
|
|
# @BRIEF Coverage tests for validation_service.py remaining branches — non-list screenshot_paths,
|
|
# source resolution fallbacks, record title fallback, time-field parse failure, update
|
|
# unknown-key skip, delete-with-runs without run IDs, trigger without provider.
|
|
# @RELATION BINDS_TO -> [Services.ValidationService]
|
|
# @TEST_EDGE: screenshot_paths_not_list -> non-list screenshot_paths yields empty list
|
|
# @TEST_EDGE: dashboard_url_without_id -> no resolved id and no title fetch
|
|
# @TEST_EDGE: unknown_source_type -> unsupported source type yields no resolved id
|
|
# @TEST_EDGE: no_sources_no_dashboards -> empty source list returned
|
|
# @TEST_EDGE: title_resolve_returns_none -> dashboard_title falls back to dashboard_id
|
|
# @TEST_EDGE: invalid_time_field -> no setattr when time parse fails
|
|
# @TEST_EDGE: unknown_update_key -> skipped when policy lacks the attribute
|
|
# @TEST_EDGE: delete_runs_without_runs -> record deletion skipped when no runs exist
|
|
# @TEST_EDGE: trigger_without_provider -> provider validation skipped when provider_id empty
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from types import SimpleNamespace
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
from datetime import UTC, datetime
|
|
import pytest
|
|
|
|
|
|
class _PolicyStub:
|
|
"""Bare policy stub — unknown attributes must NOT auto-exist (unlike MagicMock)."""
|
|
|
|
def __init__(self, **attrs):
|
|
for k, v in attrs.items():
|
|
setattr(self, k, v)
|
|
|
|
|
|
def _make_record(**overrides):
|
|
from src.models.llm import ValidationRecord as VR
|
|
record = MagicMock(spec=VR)
|
|
record.id = "rec-1"
|
|
record.run_id = "run-1"
|
|
record.policy_id = "pol-1"
|
|
record.dashboard_id = "42"
|
|
record.environment_id = "env-1"
|
|
record.status = "PASS"
|
|
record.summary = "OK"
|
|
record.issues = []
|
|
record.raw_response = None
|
|
record.screenshot_path = None
|
|
record.screenshot_paths = None
|
|
record.execution_path = None
|
|
record.dataset_health = None
|
|
record.chart_data_results = []
|
|
record.tab_screenshots = []
|
|
record.logs_sent_to_llm = []
|
|
record.token_usage = None
|
|
record.timings = None
|
|
record.timestamp = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
|
|
for k, v in overrides.items():
|
|
setattr(record, k, v)
|
|
return record
|
|
|
|
|
|
class TestExtractScreenshotPaths:
|
|
"""_extract_screenshot_paths — non-list screenshot_paths value."""
|
|
|
|
def test_screenshot_paths_not_a_list(self):
|
|
from src.services.validation_service import _extract_screenshot_paths
|
|
result = _extract_screenshot_paths('{"screenshot_paths": "single-path.png"}')
|
|
assert result == []
|
|
|
|
def test_screenshot_paths_null_value(self):
|
|
from src.services.validation_service import _extract_screenshot_paths
|
|
result = _extract_screenshot_paths('{"screenshot_paths": null}')
|
|
assert result == []
|
|
|
|
|
|
class TestResolveSourcesBranches:
|
|
"""_resolve_sources — remaining fallback branches."""
|
|
|
|
async def test_dashboard_url_without_dashboard_id(self):
|
|
"""dashboard_url parsed without dashboard_id -> no resolved id, no title fetch."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
from src.schemas.validation import SourceInput
|
|
svc = ValidationTaskService(MagicMock())
|
|
svc._parse_superset_link = AsyncMock(return_value={
|
|
"dashboard_id": None,
|
|
"partial_recovery": False,
|
|
})
|
|
svc._resolve_dashboard_title = AsyncMock(return_value="must-not-be-called")
|
|
sources_input = [SourceInput(type="dashboard_url", value="http://superset/d/abc")]
|
|
result = await svc._resolve_sources(sources_input, None, "pol-1", "env-1")
|
|
assert len(result) == 1
|
|
assert result[0].resolved_dashboard_id is None
|
|
assert result[0].status == "valid"
|
|
svc._resolve_dashboard_title.assert_not_called()
|
|
|
|
async def test_unknown_source_type(self):
|
|
"""Source type neither dashboard_url nor dashboard_id -> no resolved id."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
svc = ValidationTaskService(MagicMock())
|
|
svc._parse_superset_link = AsyncMock()
|
|
svc._resolve_dashboard_title = AsyncMock(return_value=None)
|
|
source_input = SimpleNamespace(type="dataset", value="ds-1")
|
|
result = await svc._resolve_sources([source_input], None, "pol-1", "env-1")
|
|
assert len(result) == 1
|
|
assert result[0].type == "dataset"
|
|
assert result[0].resolved_dashboard_id is None
|
|
svc._parse_superset_link.assert_not_called()
|
|
|
|
async def test_no_sources_no_dashboards(self):
|
|
"""Neither sources_input nor dashboard_ids -> empty list."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
svc = ValidationTaskService(MagicMock())
|
|
result = await svc._resolve_sources(None, None, "pol-1", "env-1")
|
|
assert result == []
|
|
|
|
|
|
class TestRecordToDictTitleFallbackNone:
|
|
"""_record_to_dict — title resolution returns None -> dashboard_id fallback."""
|
|
|
|
async def test_title_resolve_returns_none(self):
|
|
from src.services.validation_service import ValidationTaskService
|
|
record = _make_record()
|
|
source = MagicMock()
|
|
source.title = None
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = source
|
|
svc = ValidationTaskService(db)
|
|
svc._resolve_dashboard_title = AsyncMock(return_value=None)
|
|
result = await svc._record_to_dict(record)
|
|
assert result["dashboard_title"] == "42"
|
|
svc._resolve_dashboard_title.assert_called_once_with("env-1", "42")
|
|
assert source.title is None # fallback path must not write a resolved title
|
|
|
|
async def test_title_resolve_returns_none_no_env(self):
|
|
"""record without environment_id -> no Superset fallback call."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
record = _make_record(environment_id=None)
|
|
source = MagicMock()
|
|
source.title = None
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = source
|
|
svc = ValidationTaskService(db)
|
|
svc._resolve_dashboard_title = AsyncMock(return_value="must-not-be-called")
|
|
result = await svc._record_to_dict(record)
|
|
assert result["dashboard_title"] == "42"
|
|
svc._resolve_dashboard_title.assert_not_called()
|
|
|
|
|
|
class TestApplyTimeFieldParseFailure:
|
|
"""_apply_time_field — field present but unparseable -> no setattr."""
|
|
|
|
def test_invalid_time_does_not_set(self):
|
|
from src.services.validation_service import ValidationTaskService
|
|
svc = ValidationTaskService(MagicMock())
|
|
policy = MagicMock()
|
|
policy.window_start = None
|
|
svc._apply_time_field({"window_start": "not-a-time"}, policy, "window_start")
|
|
assert policy.window_start is None
|
|
|
|
def test_invalid_time_does_not_set_with_partial_minutes(self):
|
|
"""'09' (missing minutes) also fails parsing -> no setattr."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
svc = ValidationTaskService(MagicMock())
|
|
policy = MagicMock()
|
|
policy.window_start = None
|
|
svc._apply_time_field({"window_start": "09"}, policy, "window_start")
|
|
assert policy.window_start is None
|
|
|
|
|
|
class TestUpdateTaskUnknownKey:
|
|
"""update_task — update data key not present on policy is skipped."""
|
|
|
|
async def test_update_skips_unknown_key(self):
|
|
from src.services.validation_service import ValidationTaskService
|
|
from src.schemas.validation import ValidationTaskUpdate
|
|
policy = _PolicyStub(id="pol-1", name="Test Task")
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = policy
|
|
svc = ValidationTaskService(db, config_manager=MagicMock())
|
|
svc._policy_to_response = MagicMock(return_value=MagicMock())
|
|
payload = MagicMock(spec=ValidationTaskUpdate)
|
|
payload.model_dump.return_value = {"unknown_field": "x"}
|
|
await svc.update_task("pol-1", payload)
|
|
assert not hasattr(policy, "unknown_field")
|
|
db.commit.assert_called_once()
|
|
|
|
|
|
class TestDeleteTaskRuns:
|
|
"""delete_task — delete_runs=True but no run IDs found."""
|
|
|
|
def test_delete_with_runs_but_no_run_ids(self):
|
|
from src.services.validation_service import ValidationTaskService
|
|
policy = MagicMock()
|
|
policy.id = "pol-1"
|
|
policy.name = "Test Task"
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = policy
|
|
db.query.return_value.filter.return_value.all.return_value = []
|
|
svc = ValidationTaskService(db)
|
|
svc.delete_task("pol-1", delete_runs=True)
|
|
db.delete.assert_called_once_with(policy)
|
|
db.commit.assert_called_once()
|
|
|
|
|
|
class TestTriggerRunNoProvider:
|
|
"""trigger_run — policy without provider_id skips provider validation."""
|
|
|
|
async def test_trigger_without_provider_id(self):
|
|
from src.services.validation_service import ValidationTaskService
|
|
policy = MagicMock()
|
|
policy.id = "pol-1"
|
|
policy.name = "Test Task"
|
|
policy.environment_id = "env-1"
|
|
policy.provider_id = None
|
|
policy.dashboard_ids = ["42"]
|
|
policy.screenshot_enabled = True
|
|
policy.logs_enabled = True
|
|
policy.execute_chart_data = False
|
|
policy.llm_batch_size = None
|
|
policy.policy_dashboard_concurrency_limit = None
|
|
policy.prompt_template = None
|
|
policy.sources = []
|
|
|
|
db = MagicMock()
|
|
|
|
def query_fn(model):
|
|
m = MagicMock()
|
|
if model.__name__ == "ValidationSource":
|
|
m.filter.return_value.all.return_value = []
|
|
elif model.__name__ == "ValidationRun":
|
|
m.filter.return_value.first.return_value = None
|
|
m.filter.return_value.count.return_value = 0
|
|
elif model.__name__ == "ValidationPolicy":
|
|
m.filter.return_value.first.return_value = policy
|
|
return m
|
|
|
|
db.query = query_fn
|
|
cm = MagicMock()
|
|
cm.configure_mock(**{"config.settings.global_validation_worker_limit": 5})
|
|
svc = ValidationTaskService(db, config_manager=cm)
|
|
|
|
task_manager = AsyncMock()
|
|
task = MagicMock()
|
|
task.id = "spawned-task-1"
|
|
task_manager.create_task.return_value = task
|
|
|
|
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
|
|
result = await svc.trigger_run("pol-1", task_manager)
|
|
|
|
assert result["task_id"] == "spawned-task-1"
|
|
mock_cls.assert_not_called()
|
|
|
|
async def test_trigger_without_provider_and_no_dashboards(self):
|
|
"""provider_id empty + no sources/dashboard_ids -> ValueError."""
|
|
from src.services.validation_service import ValidationTaskService
|
|
policy = MagicMock()
|
|
policy.id = "pol-1"
|
|
policy.name = "Test Task"
|
|
policy.provider_id = None
|
|
policy.dashboard_ids = []
|
|
policy.sources = []
|
|
|
|
db = MagicMock()
|
|
|
|
def query_fn(model):
|
|
m = MagicMock()
|
|
if model.__name__ == "ValidationSource":
|
|
m.filter.return_value.all.return_value = []
|
|
elif model.__name__ == "ValidationRun":
|
|
m.filter.return_value.first.return_value = None
|
|
m.filter.return_value.count.return_value = 0
|
|
elif model.__name__ == "ValidationPolicy":
|
|
m.filter.return_value.first.return_value = policy
|
|
return m
|
|
|
|
db.query = query_fn
|
|
cm = MagicMock()
|
|
cm.configure_mock(**{"config.settings.global_validation_worker_limit": 5})
|
|
svc = ValidationTaskService(db, config_manager=cm)
|
|
|
|
with pytest.raises(ValueError, match="no dashboard IDs"):
|
|
await svc.trigger_run("pol-1", AsyncMock())
|
|
# #endregion Test.ValidationTaskService.Branches
|