test(services): add unit tests for profile_preference, resource, validation_service

- profile_preference_service: 19 tests, 100% coverage (CRUD, validation, encryption,
  DTO conversion with mocked AuthRepository and EncryptionManager)
- resource_service: 50 tests, 100% coverage (dashboard/dataset enrichment with
  git/task status, pagination, activity summary, datetime normalization)
- validation_service: 63 tests across 3 files, 97% coverage (provider validation,
  environment validation, source resolution, run/record conversion, trigger_run,
  create/update/delete tasks, list/filter runs, get_run_detail)
This commit is contained in:
2026-06-10 14:57:11 +03:00
parent c33792c8ee
commit 442596a3dc
5 changed files with 2214 additions and 0 deletions

View File

@@ -0,0 +1,465 @@
# #region Test.ProfilePreferenceService [C:3] [TYPE Module] [SEMANTICS test,profile,preference,crud]
# @BRIEF Tests for profile_preference_service.py — get, update, validation, encryption.
# @RELATION BINDS_TO -> [ProfilePreferenceService]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import MagicMock, patch, ANY
from datetime import UTC, datetime
import pytest
# Set ENCRYPTION_KEY before importing anything that uses EncryptionManager
import os
os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("ENCRYPTION_KEY", "MldUHg5kwSAcPnnYxmhWDS6ASb6e_bWQRV5gtwHrjQ0=")
from src.schemas.profile import ProfilePreferenceUpdateRequest
def make_user(user_id="user-1", **kwargs):
from src.models.auth import User
user = MagicMock(spec=User)
user.id = user_id
user.roles = []
user.auth_source = "database"
for k, v in kwargs.items():
setattr(user, k, v)
return user
def make_preference_row(user_id="user-1", **overrides):
from src.models.profile import UserDashboardPreference
pref = MagicMock(spec=UserDashboardPreference)
pref.user_id = user_id
pref.superset_username = None
pref.superset_username_normalized = None
pref.show_only_my_dashboards = False
pref.show_only_slug_dashboards = True
pref.git_username = None
pref.git_email = None
pref.git_personal_access_token_encrypted = None
pref.start_page = "dashboards"
pref.auto_open_task_drawer = True
pref.dashboards_table_density = "comfortable"
pref.telegram_id = None
pref.email_address = None
pref.notify_on_fail = True
pref.created_at = datetime.now(UTC)
pref.updated_at = datetime.now(UTC)
for k, v in overrides.items():
setattr(pref, k, v)
return pref
class TestGetMyPreference:
"""get_my_preference — return user preference or default."""
@pytest.fixture
def _make_security_summary(self):
from src.schemas.profile import ProfileSecuritySummary
return ProfileSecuritySummary(read_only=True, auth_source="database")
@pytest.fixture
def service(self, _make_security_summary):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository") as mock_auth_repo_cls, \
patch("src.services.profile_preference_service.EncryptionManager"), \
patch("src.services.profile_preference_service.SecurityBadgeService") as mock_badge_cls:
mock_auth = MagicMock()
mock_auth_repo_cls.return_value = mock_auth
mock_badge = MagicMock()
mock_badge.build_security_summary.return_value = _make_security_summary
mock_badge_cls.return_value = mock_badge
svc = ProfilePreferenceService(db, config_mgr)
svc.auth_repository = mock_auth
svc.security_badge_service = mock_badge
yield svc
def test_no_preference_returns_default(self, service):
service.auth_repository.get_user_dashboard_preference.return_value = None
user = make_user()
result = service.get_my_preference(user)
assert result.status == "success"
assert "not configured" in (result.message or "").lower()
assert result.preference.user_id == "user-1"
assert result.preference.superset_username is None
assert result.preference.show_only_my_dashboards is False
def test_existing_preference(self, service):
pref = make_preference_row(
superset_username="admin",
superset_username_normalized="admin",
show_only_my_dashboards=True,
)
service.auth_repository.get_user_dashboard_preference.return_value = pref
user = make_user()
result = service.get_my_preference(user)
assert result.status == "success"
assert result.preference.show_only_my_dashboards is True
assert result.preference.superset_username_normalized == "admin"
def test_security_summary_included(self, service, _make_security_summary):
service.auth_repository.get_user_dashboard_preference.return_value = None
user = make_user()
result = service.get_my_preference(user)
assert result.security is not None
service.security_badge_service.build_security_summary.assert_called_once_with(user)
class TestGetDashboardFilterBinding:
"""get_dashboard_filter_binding — dashboard filter fields only."""
@pytest.fixture
def service(self):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository") as mock_auth_repo_cls, \
patch("src.services.profile_preference_service.EncryptionManager"), \
patch("src.services.profile_preference_service.SecurityBadgeService"):
mock_auth = MagicMock()
mock_auth_repo_cls.return_value = mock_auth
svc = ProfilePreferenceService(db, config_mgr)
svc.auth_repository = mock_auth
yield svc
def test_no_preference(self, service):
service.auth_repository.get_user_dashboard_preference.return_value = None
user = make_user()
result = service.get_dashboard_filter_binding(user)
assert result["superset_username"] is None
assert result["superset_username_normalized"] is None
assert result["show_only_my_dashboards"] is False
assert result["show_only_slug_dashboards"] is True
def test_with_preference(self, service):
pref = make_preference_row(
superset_username=" Admin ",
show_only_my_dashboards=True,
show_only_slug_dashboards=False,
)
service.auth_repository.get_user_dashboard_preference.return_value = pref
user = make_user()
result = service.get_dashboard_filter_binding(user)
assert result["superset_username"] == "Admin"
assert result["superset_username_normalized"] == "admin"
assert result["show_only_my_dashboards"] is True
assert result["show_only_slug_dashboards"] is False
class TestUpdateMyPreference:
"""update_my_preference — validate and persist preference."""
@pytest.fixture
def _make_security_summary(self):
from src.schemas.profile import ProfileSecuritySummary
return ProfileSecuritySummary(read_only=True, auth_source="database")
@pytest.fixture
def service(self, _make_security_summary):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository") as mock_auth_repo_cls, \
patch("src.services.profile_preference_service.EncryptionManager") as mock_enc_cls, \
patch("src.services.profile_preference_service.SecurityBadgeService") as mock_badge_cls:
mock_auth = MagicMock()
mock_auth_repo_cls.return_value = mock_auth
mock_enc = MagicMock()
mock_enc.encrypt.return_value = "encrypted-token"
mock_enc.decrypt.return_value = "decrypted-token"
mock_enc_cls.return_value = mock_enc
mock_badge = MagicMock()
mock_badge.build_security_summary.return_value = _make_security_summary
mock_badge_cls.return_value = mock_badge
svc = ProfilePreferenceService(db, config_mgr)
svc.auth_repository = mock_auth
svc.encryption = mock_enc
svc.security_badge_service = mock_badge
yield svc
def test_cross_user_blocked(self, service):
from src.services.profile_utils import ProfileAuthorizationError
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = None
payload.show_only_my_dashboards = None
with pytest.raises(ProfileAuthorizationError):
service.update_my_preference(user, payload, target_user_id="user-other")
def test_validation_error(self, service):
from src.services.profile_utils import ProfileValidationError
service.auth_repository.get_user_dashboard_preference.return_value = None
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = "admin user" # has space
payload.show_only_my_dashboards = None
payload.show_only_slug_dashboards = None
payload.git_username = None
payload.git_email = None
payload.git_personal_access_token = None
payload.start_page = None
payload.auto_open_task_drawer = None
payload.dashboards_table_density = "spacious" # invalid
payload.telegram_id = None
payload.email_address = None
payload.notify_on_fail = None
payload.model_fields_set = {"superset_username", "dashboards_table_density"}
with pytest.raises(ProfileValidationError) as exc:
service.update_my_preference(user, payload)
assert len(exc.value.errors) >= 1
def test_successful_update_new_preference(self, service):
service.auth_repository.get_user_dashboard_preference.return_value = None
saved_pref = make_preference_row(
superset_username="admin",
superset_username_normalized="admin",
show_only_my_dashboards=True,
git_personal_access_token_encrypted="encrypted-token",
)
service.auth_repository.save_user_dashboard_preference.return_value = saved_pref
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = "admin"
payload.show_only_my_dashboards = True
payload.show_only_slug_dashboards = None
payload.git_username = None
payload.git_email = None
payload.git_personal_access_token = "new-token"
payload.start_page = None
payload.auto_open_task_drawer = None
payload.dashboards_table_density = None
payload.telegram_id = None
payload.email_address = None
payload.notify_on_fail = None
payload.model_fields_set = {"superset_username", "show_only_my_dashboards", "git_personal_access_token"}
result = service.update_my_preference(user, payload)
assert result.status == "success"
assert result.preference.show_only_my_dashboards is True
assert result.preference.has_git_personal_access_token is True
service.encryption.encrypt.assert_called_once_with("new-token")
def test_clear_git_token(self, service):
# When git_personal_access_token is empty string, clear existing token
existing = make_preference_row(
git_personal_access_token_encrypted="existing-encrypted",
)
service.auth_repository.get_user_dashboard_preference.return_value = existing
saved_pref = make_preference_row(
git_personal_access_token_encrypted=None,
)
service.auth_repository.save_user_dashboard_preference.return_value = saved_pref
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = None
payload.show_only_my_dashboards = None
payload.show_only_slug_dashboards = None
payload.git_username = None
payload.git_email = None
payload.git_personal_access_token = ""
payload.start_page = None
payload.auto_open_task_drawer = None
payload.dashboards_table_density = None
payload.telegram_id = None
payload.email_address = None
payload.notify_on_fail = None
payload.model_fields_set = {"git_personal_access_token"}
result = service.update_my_preference(user, payload)
assert result.status == "success"
def test_update_existing_fields(self, service):
existing = make_preference_row(
superset_username="old_user",
superset_username_normalized="old_user",
start_page="reports",
dashboards_table_density="compact",
telegram_id="@old",
email_address="old@x.com",
notify_on_fail=False,
)
service.auth_repository.get_user_dashboard_preference.return_value = existing
saved_pref = make_preference_row(
superset_username="new_user",
superset_username_normalized="new_user",
)
service.auth_repository.save_user_dashboard_preference.return_value = saved_pref
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = "new_user"
payload.show_only_my_dashboards = None
payload.show_only_slug_dashboards = None
payload.git_username = None
payload.git_email = None
payload.git_personal_access_token = None
payload.start_page = None
payload.auto_open_task_drawer = None
payload.dashboards_table_density = None
payload.telegram_id = "@new_telegram"
payload.email_address = None
payload.notify_on_fail = None
payload.model_fields_set = {"superset_username", "telegram_id"}
result = service.update_my_preference(user, payload)
assert result.status == "success"
def test_all_fields_update(self, service):
"""Cover all provided_fields branches: show_only_slug, git_username, git_email,
start_page, auto_open_task_drawer, email_address, notify_on_fail."""
existing = make_preference_row(
superset_username="old", superset_username_normalized="old",
show_only_my_dashboards=False, show_only_slug_dashboards=False,
git_username="old_git", git_email="old@x.com",
start_page="reports", auto_open_task_drawer=False,
dashboards_table_density="compact",
telegram_id="@old", email_address="old@x.com", notify_on_fail=False,
)
service.auth_repository.get_user_dashboard_preference.return_value = existing
saved_pref = make_preference_row(
superset_username="new", superset_username_normalized="new",
)
service.auth_repository.save_user_dashboard_preference.return_value = saved_pref
user = make_user("user-1")
payload = MagicMock(spec=ProfilePreferenceUpdateRequest)
payload.superset_username = None
payload.show_only_my_dashboards = True
payload.show_only_slug_dashboards = True
payload.git_username = "new_git"
payload.git_email = "new@x.com"
payload.git_personal_access_token = None
payload.start_page = "datasets"
payload.auto_open_task_drawer = True
payload.dashboards_table_density = "compact"
payload.telegram_id = None
payload.email_address = "new_email@x.com"
payload.notify_on_fail = True
payload.model_fields_set = {
"show_only_my_dashboards", "show_only_slug_dashboards",
"git_username", "git_email", "start_page",
"auto_open_task_drawer", "email_address", "notify_on_fail",
}
result = service.update_my_preference(user, payload)
assert result.status == "success"
class TestToPreferencePayload:
"""_to_preference_payload — ORM row to API DTO."""
@pytest.fixture
def service(self):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository"), \
patch("src.services.profile_preference_service.EncryptionManager") as mock_enc_cls, \
patch("src.services.profile_preference_service.SecurityBadgeService"):
mock_enc = MagicMock()
mock_enc.decrypt.return_value = "my-secret-token"
mock_enc_cls.return_value = mock_enc
svc = ProfilePreferenceService(db, config_mgr)
svc.encryption = mock_enc
yield svc
def test_maps_all_fields(self, service):
pref = make_preference_row(
superset_username="admin",
superset_username_normalized="admin",
git_username="jdoe",
git_email="j@x.com",
git_personal_access_token_encrypted="encrypted-val",
start_page="datasets",
telegram_id="@admin",
email_address="admin@x.com",
)
result = service._to_preference_payload(pref, "user-1")
assert result.user_id == "user-1"
assert result.superset_username == "admin"
assert result.git_username == "jdoe"
assert result.git_email == "j@x.com"
assert result.has_git_personal_access_token is True
assert result.start_page == "datasets"
assert result.telegram_id == "@admin"
assert result.email_address == "admin@x.com"
def test_no_token(self, service):
pref = make_preference_row(
git_personal_access_token_encrypted=None,
)
result = service._to_preference_payload(pref, "user-1")
assert result.has_git_personal_access_token is False
assert result.git_personal_access_token_masked is None
def test_decryption_failure_fallback(self, service):
service.encryption.decrypt.side_effect = Exception("Decrypt failed")
pref = make_preference_row(
git_personal_access_token_encrypted="corrupted",
)
result = service._to_preference_payload(pref, "user-1")
assert result.has_git_personal_access_token is True
assert result.git_personal_access_token_masked == "***"
class TestGetOrCreatePreferenceRow:
"""_get_or_create_preference_row — returns existing or new."""
@pytest.fixture
def service(self):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository") as mock_auth_repo_cls, \
patch("src.services.profile_preference_service.EncryptionManager"), \
patch("src.services.profile_preference_service.SecurityBadgeService"):
mock_auth = MagicMock()
mock_auth_repo_cls.return_value = mock_auth
svc = ProfilePreferenceService(db, config_mgr)
svc.auth_repository = mock_auth
yield svc
def test_existing_returns_row(self, service):
pref = make_preference_row()
service.auth_repository.get_user_dashboard_preference.return_value = pref
result = service._get_or_create_preference_row("user-1")
assert result is pref
def test_missing_creates_new(self, service):
from src.models.profile import UserDashboardPreference
service.auth_repository.get_user_dashboard_preference.return_value = None
result = service._get_or_create_preference_row("user-1")
assert isinstance(result, UserDashboardPreference)
assert result.user_id == "user-1"
class TestBuildDefaultPreference:
"""_build_default_preference — delegates to profile_utils."""
def test_delegates_correctly(self):
from src.services.profile_preference_service import ProfilePreferenceService
db = MagicMock()
config_mgr = MagicMock()
with patch("src.services.profile_preference_service.AuthRepository"), \
patch("src.services.profile_preference_service.EncryptionManager"), \
patch("src.services.profile_preference_service.SecurityBadgeService"), \
patch("src.services.profile_preference_service.build_default_preference") as mock_build:
mock_build.return_value = MagicMock()
svc = ProfilePreferenceService(db, config_mgr)
result = svc._build_default_preference("user-1")
assert result is mock_build.return_value
mock_build.assert_called_once_with("user-1")
# #endregion Test.ProfilePreferenceService

View File

@@ -0,0 +1,578 @@
# #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
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."""
def test_no_repo(self):
from src.services.resource_service import ResourceService
svc = ResourceService()
svc.git_service = MagicMock()
svc.git_service.get_repo.return_value = None
result = svc._get_git_status_for_dashboard(42)
assert result["sync_status"] == "NO_REPO"
assert result["has_repo"] is False
def test_repo_error(self):
from src.services.resource_service import ResourceService
svc = ResourceService()
svc.git_service = MagicMock()
svc.git_service.get_repo.side_effect = Exception("Git error")
result = svc._get_git_status_for_dashboard(42)
assert result["sync_status"] == "NO_REPO"
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.return_value = repo
result = svc._get_git_status_for_dashboard(42)
assert result["sync_status"] == "ERROR"
assert result["has_repo"] is True
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.return_value = repo
# Mock gitpython's iter_commits functionality
repo.iter_commits.return_value = []
result = svc._get_git_status_for_dashboard(42)
assert result["sync_status"] == "OK"
assert result["has_repo"] is True
assert result["branch"] == "main"
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.return_value = repo
repo.iter_commits.return_value = []
result = 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 = MagicMock(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 = MagicMock(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

View File

@@ -0,0 +1,426 @@
# #region Test.ValidationTaskService.Crud [C:3] [TYPE Module] [SEMANTICS test,validation,crud,task,run]
# @BRIEF Tests for validation_service.py — CRUD: get/list/delete tasks, trigger runs, run history, run detail.
# @RELATION BINDS_TO -> [ValidationService]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import MagicMock, patch
from datetime import UTC, datetime
import pytest
def make_policy(**overrides):
"""Create a mock ValidationPolicy."""
policy = MagicMock()
policy.id = "pol-1"
policy.name = "Test Task"
policy.description = None
policy.environment_id = "env-1"
policy.is_active = True
policy.dashboard_ids = []
policy.provider_id = "prov-1"
policy.schedule_days = None
policy.window_start = None
policy.window_end = None
policy.notify_owners = None
policy.custom_channels = None
policy.alert_condition = None
policy.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy.updated_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy.sources = []
policy.screenshot_enabled = True
policy.logs_enabled = True
policy.execute_chart_data = False
policy.llm_batch_size = 1
policy.policy_dashboard_concurrency_limit = 3
policy.prompt_template = None
for k, v in overrides.items():
setattr(policy, k, v)
return policy
def make_valid_query_mock(policies=None):
"""Build a SQLAlchemy query mock chain that works for both filtered and unfiltered paths."""
m = MagicMock()
policies = policies or []
first_result = policies[0] if policies else None
m.configure_mock(**{"count.return_value": len(policies)})
m.configure_mock(**{"order_by.return_value.offset.return_value.limit.return_value.all.return_value": policies})
# query.filter(...).first() — returns first policy
m.configure_mock(**{"filter.return_value.first.return_value": first_result})
# query.filter(...).count()
m.configure_mock(**{"filter.return_value.count.return_value": len(policies)})
# query.filter(...).order_by(...).first() — used by _policy_to_response for last_run
m.configure_mock(**{"filter.return_value.order_by.return_value.first.return_value": None})
# query.filter(...).order_by(...).offset(...).limit(...).all()
m.configure_mock(**{"filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value": policies})
return m
class TestGetTask:
"""get_task — find by id."""
def test_not_found_raises(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
with pytest.raises(ValueError, match="not found"):
svc.get_task("nonexistent")
def test_found(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query = lambda model: make_valid_query_mock() if model.__name__ == "ValidationRun" else make_valid_query_mock([policy])
svc = ValidationTaskService(db)
result = svc.get_task("pol-1")
assert result.id == "pol-1"
class TestListTasks:
"""list_tasks — filtering and pagination."""
def _make_qm(self, policies=None):
"""Build a query mock that works for both filtered and unfiltered paths."""
return make_valid_query_mock(policies)
def test_no_filters(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query = lambda model: self._make_qm() if model.__name__ == "ValidationRun" else self._make_qm([policy])
svc = ValidationTaskService(db)
total, tasks = svc.list_tasks()
assert total == 1
assert len(tasks) == 1
assert tasks[0].id == "pol-1"
def test_filter_active(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query = lambda model: self._make_qm() if model.__name__ == "ValidationRun" else self._make_qm([policy])
svc = ValidationTaskService(db)
total, tasks = svc.list_tasks(is_active=True)
assert total == 1
assert len(tasks) == 1
def test_filter_environment_id(self):
"""Cover list_tasks line 398: environment_id filter."""
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query = lambda model: self._make_qm() if model.__name__ == "ValidationRun" else self._make_qm([policy])
svc = ValidationTaskService(db)
total, tasks = svc.list_tasks(environment_id="env-1")
assert total == 1
def test_filter_search(self):
"""Cover list_tasks line 400: search/ilike filter."""
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query = lambda model: self._make_qm() if model.__name__ == "ValidationRun" else self._make_qm([policy])
svc = ValidationTaskService(db)
total, tasks = svc.list_tasks(search="Test")
assert total == 1
class TestDeleteTask:
"""delete_task — with/without runs."""
def test_not_found_raises(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
with pytest.raises(ValueError, match="not found"):
svc.delete_task("nonexistent")
def test_delete_without_runs(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
svc = ValidationTaskService(db)
svc.delete_task("pol-1", delete_runs=False)
db.delete.assert_called_once_with(policy)
db.commit.assert_called_once()
def test_delete_with_runs(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
db.query.return_value.filter.return_value.all.return_value = [("run-1",)]
svc = ValidationTaskService(db)
svc.delete_task("pol-1", delete_runs=True)
db.delete.assert_called_once_with(policy)
db.commit.assert_called_once()
class TestCreateTask:
"""create_task — full CRUD with provider/environment validation."""
def test_creates_policy_with_sources(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskCreate, SourceInput
db = MagicMock()
db.add = MagicMock()
db.flush = MagicMock()
db.commit = MagicMock()
db.refresh = MagicMock()
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = True
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
cm = MagicMock()
cm.get_environment.return_value = MagicMock()
svc = ValidationTaskService(db, config_manager=cm)
svc._resolve_sources = MagicMock(return_value=[
MagicMock(type="dashboard_id", value="42",
resolved_dashboard_id="42", status="valid", title=None),
])
# Mock _policy_to_response to avoid ORM serialization issues
svc._policy_to_response = MagicMock(return_value=MagicMock())
payload = MagicMock(spec=ValidationTaskCreate)
payload.name = "New Task"
payload.description = "Test desc"
payload.environment_id = "env-1"
payload.is_active = True
payload.dashboard_ids = ["42"]
payload.provider_id = "prov-1"
payload.schedule_days = ["MON", "WED", "FRI"]
payload.window_start = None
payload.window_end = None
payload.notify_owners = True
payload.custom_channels = None
payload.alert_condition = "FAIL_ONLY"
payload.screenshot_enabled = True
payload.logs_enabled = True
payload.execute_chart_data = False
payload.llm_batch_size = 1
payload.policy_dashboard_concurrency_limit = 3
payload.prompt_template = None
payload.sources = None
result = svc.create_task(payload)
assert db.commit.called
svc._resolve_sources.assert_called_once()
def test_creates_with_dashboard_url_sources(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskCreate
db = MagicMock()
db.add = MagicMock()
db.flush = MagicMock()
db.commit = MagicMock()
db.refresh = MagicMock()
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = False # screenshot_enabled=False
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
cm = MagicMock()
cm.get_environment.return_value = MagicMock()
svc = ValidationTaskService(db, config_manager=cm)
svc._resolve_sources = MagicMock(return_value=[
MagicMock(type="dashboard_url", value="http://superset/d/42",
resolved_dashboard_id="42", status="valid", title=None),
])
svc._policy_to_response = MagicMock(return_value=MagicMock())
payload = MagicMock(spec=ValidationTaskCreate)
payload.name = "URL Task"
payload.description = None
payload.environment_id = "env-1"
payload.is_active = True
payload.dashboard_ids = None
payload.provider_id = "prov-1"
payload.schedule_days = []
payload.window_start = "09:00"
payload.window_end = "18:00"
payload.notify_owners = None
payload.custom_channels = [{"type": "SLACK", "target": "#alerts"}]
payload.alert_condition = "ALWAYS"
payload.screenshot_enabled = False
payload.logs_enabled = True
payload.execute_chart_data = True
payload.llm_batch_size = 3
payload.policy_dashboard_concurrency_limit = 5
payload.prompt_template = "Check {{dashboard}}"
payload.sources = None
result = svc.create_task(payload)
assert db.commit.called
class TestUpdateTask:
"""update_task — partial update with re-validation."""
def test_not_found_raises(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskUpdate
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
payload = MagicMock(spec=ValidationTaskUpdate)
payload.model_dump.return_value = {"name": "Updated"}
with pytest.raises(ValueError, match="not found"):
svc.update_task("nonexistent", payload)
def test_sources_update_with_none_is_skipped(self):
"""Cover _apply_sources_update line 502: sources_data is None."""
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskUpdate
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
svc = ValidationTaskService(db, config_manager=MagicMock())
payload = MagicMock(spec=ValidationTaskUpdate)
payload.model_dump.return_value = {"sources": None}
result = svc.update_task("pol-1", payload)
assert db.commit.called
def test_updates_name_only(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskUpdate
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
svc = ValidationTaskService(db, config_manager=MagicMock())
payload = MagicMock(spec=ValidationTaskUpdate)
payload.model_dump.return_value = {"name": "Updated Name"}
result = svc.update_task("pol-1", payload)
assert db.commit.called
def test_updates_provider_and_environment(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskUpdate
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
cm = MagicMock()
cm.get_environment.return_value = MagicMock()
svc = ValidationTaskService(db, config_manager=cm)
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = True
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
payload = MagicMock(spec=ValidationTaskUpdate)
payload.model_dump.return_value = {
"provider_id": "prov-2",
"environment_id": "env-2",
"screenshot_enabled": True,
"window_start": "10:00",
"window_end": "20:00",
}
result = svc.update_task("pol-1", payload)
assert db.commit.called
def test_updates_with_sources(self):
from src.services.validation_service import ValidationTaskService
from src.schemas.validation import ValidationTaskUpdate, SourceInput
policy = make_policy()
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = policy
svc = ValidationTaskService(db, config_manager=MagicMock())
svc._resolve_sources = MagicMock(return_value=[
MagicMock(type="dashboard_id", value="99",
resolved_dashboard_id="99", status="valid", title="New"),
])
payload = MagicMock(spec=ValidationTaskUpdate)
payload.model_dump.return_value = {
"name": "Updated with sources",
"sources": [SourceInput(type="dashboard_id", value="99")],
}
result = svc.update_task("pol-1", payload)
assert db.commit.called
svc._resolve_sources.assert_called_once()
class TestGetRunHistory:
"""get_run_history — paginated run list for a policy."""
def test_empty(self):
from src.services.validation_service import ValidationTaskService
from src.models.llm import ValidationRun
db = MagicMock()
m = MagicMock()
m.configure_mock(**{"count.return_value": 0})
m.configure_mock(**{"order_by.return_value.offset.return_value.limit.return_value.all.return_value": []})
db.query.return_value.filter.return_value = m
svc = ValidationTaskService(db)
total, runs = svc.get_run_history("pol-1")
assert total == 0
assert runs == []
def test_with_runs(self):
from src.services.validation_service import ValidationTaskService
run = MagicMock()
run.id = "run-1"
run.policy_id = "pol-1"
run.task_id = "task-1"
run.status = "completed"
run.trigger = "manual"
run.started_at = None
run.finished_at = None
run.dashboard_count = 3
run.pass_count = 2
run.warn_count = 1
run.fail_count = 0
run.unknown_count = 0
run.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
db = MagicMock()
m = MagicMock()
m.configure_mock(**{"count.return_value": 1})
m.configure_mock(**{"order_by.return_value.offset.return_value.limit.return_value.all.return_value": [run]})
db.query.return_value.filter.return_value = m
svc = ValidationTaskService(db)
total, runs = svc.get_run_history("pol-1")
assert total == 1
assert len(runs) == 1
assert runs[0]["status"] == "completed"
# #endregion Test.ValidationTaskService.Crud

View File

@@ -0,0 +1,427 @@
# #region Test.ValidationTaskService.Helpers [C:3] [TYPE Module] [SEMANTICS test,validation,helpers,utilities]
# @BRIEF Tests for validation_service.py — helper functions, provider/environment validation, run/record conversion, source resolution, time fields.
# @RELATION BINDS_TO -> [ValidationService]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import MagicMock, patch
from datetime import UTC, datetime, time
import pytest
class TestHelperFunctions:
"""_extract_screenshot_paths, _parse_time, _format_time."""
def test_extract_screenshot_paths_none(self):
from src.services.validation_service import _extract_screenshot_paths
assert _extract_screenshot_paths(None) == []
def test_extract_screenshot_paths_valid(self):
from src.services.validation_service import _extract_screenshot_paths
result = _extract_screenshot_paths('{"screenshot_paths": ["/path/1.png", "/path/2.png"]}')
assert result == ["/path/1.png", "/path/2.png"]
def test_extract_screenshot_paths_empty_json(self):
from src.services.validation_service import _extract_screenshot_paths
result = _extract_screenshot_paths('{"other": "data"}')
assert result == []
def test_extract_screenshot_paths_invalid_json(self):
from src.services.validation_service import _extract_screenshot_paths
result = _extract_screenshot_paths("not-json")
assert result == []
def test_parse_time_none(self):
from src.services.validation_service import _parse_time
assert _parse_time(None) is None
def test_parse_time_valid(self):
from src.services.validation_service import _parse_time
result = _parse_time("09:30")
assert result == time(9, 30)
def test_parse_time_invalid(self):
from src.services.validation_service import _parse_time
assert _parse_time("abc") is None
def test_format_time_none(self):
from src.services.validation_service import _format_time
assert _format_time(None) is None
def test_format_time_valid(self):
from src.services.validation_service import _format_time
assert _format_time(time(14, 5)) == "14:05"
class TestValidateProvider:
"""_validate_provider — existence, active, multimodal checks."""
@pytest.fixture
def service(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
return ValidationTaskService(db, config_manager=None)
def test_empty_provider_id_raises(self, service):
with pytest.raises(ValueError, match="provider_id is required"):
service._validate_provider("")
def test_none_provider_id_raises(self, service):
with pytest.raises(ValueError, match="provider_id is required"):
service._validate_provider(" ")
def test_not_found_raises(self, service):
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
mock_svc.get_provider.return_value = None
mock_cls.return_value = mock_svc
with pytest.raises(ValueError, match="not found"):
service._validate_provider("prov-1")
def test_inactive_raises(self, service):
provider = MagicMock()
provider.is_active = False
provider.name = "GPT-4"
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
with pytest.raises(ValueError, match="not active"):
service._validate_provider("prov-1")
def test_non_multimodal_raises(self, service):
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = False
provider.name = "GPT-4"
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
with pytest.raises(ValueError, match="not multimodal"):
service._validate_provider("prov-1", require_multimodal=True)
def test_multimodal_not_required(self, service):
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = False
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
result = service._validate_provider("prov-1", require_multimodal=False)
assert result is provider
def test_valid_multimodal(self, service):
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = True
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
result = service._validate_provider("prov-1")
assert result is provider
class TestValidateEnvironment:
"""_validate_environment — existence check."""
def test_no_config_manager_skips(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock(), config_manager=None)
svc._validate_environment("env-1")
def test_environment_found(self):
from src.services.validation_service import ValidationTaskService
cm = MagicMock()
cm.get_environment.return_value = MagicMock()
svc = ValidationTaskService(MagicMock(), config_manager=cm)
svc._validate_environment("env-1")
def test_environment_not_found(self):
from src.services.validation_service import ValidationTaskService
cm = MagicMock()
cm.get_environment.return_value = None
svc = ValidationTaskService(MagicMock(), config_manager=cm)
with pytest.raises(ValueError, match="not found"):
svc._validate_environment("env-unknown")
class TestRunToDict:
"""_run_to_dict — conversion with duration_ms."""
def make_run(self, **overrides):
run = MagicMock()
run.id = "run-1"
run.policy_id = "pol-1"
run.task_id = "task-1"
run.status = "completed"
run.trigger = "manual"
run.dashboard_count = 5
run.pass_count = 3
run.warn_count = 1
run.fail_count = 1
run.unknown_count = 0
run.started_at = None
run.finished_at = None
run.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
for k, v in overrides.items():
setattr(run, k, v)
return run
def test_no_dates(self):
from src.services.validation_service import ValidationTaskService
result = ValidationTaskService(MagicMock())._run_to_dict(self.make_run())
assert result["duration_ms"] is None
assert result["status"] == "completed"
def test_with_dates_computes_duration(self):
from src.services.validation_service import ValidationTaskService
run = self.make_run(
started_at=datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC),
finished_at=datetime(2026, 1, 1, 12, 3, 30, tzinfo=UTC),
)
result = ValidationTaskService(MagicMock())._run_to_dict(run)
assert result["duration_ms"] == 210000
def test_naive_datetime_utc(self):
from src.services.validation_service import ValidationTaskService
run = self.make_run(
started_at=datetime(2026, 1, 1, 12, 0, 0),
finished_at=datetime(2026, 1, 1, 12, 1, 0),
)
result = ValidationTaskService(MagicMock())._run_to_dict(run)
assert result["duration_ms"] == 60000
class TestRecordToDict:
"""_record_to_dict — conversion with title resolution."""
def make_record(self, **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
def test_basic(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
result = svc._record_to_dict(self.make_record())
assert result["status"] == "PASS"
assert result["screenshot_paths"] == []
def test_with_source_title(self):
from src.services.validation_service import ValidationTaskService
source = MagicMock()
source.title = "Sales Dashboard"
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = source
svc = ValidationTaskService(db)
result = svc._record_to_dict(self.make_record())
assert result["dashboard_title"] == "Sales Dashboard"
def test_screenshot_paths_fallback(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
result = svc._record_to_dict(self.make_record(
raw_response='{"screenshot_paths": ["/path/img.png"]}',
))
assert result["screenshot_paths"] == ["/path/img.png"]
class TestResolveSources:
"""_resolve_sources — source resolution from input."""
def test_no_sources(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
svc._parse_superset_link = MagicMock()
svc._resolve_dashboard_title = MagicMock(return_value=None)
result = svc._resolve_sources(None, None, "pol-1", "env-1")
assert result == []
def test_dashboard_id_sources(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
svc._resolve_dashboard_title = MagicMock(return_value="Sales Dashboard")
result = svc._resolve_sources(None, ["42", "99"], "pol-1", "env-1")
assert len(result) == 2
assert result[0].type == "dashboard_id"
assert result[0].value == "42"
assert result[0].title == "Sales Dashboard"
assert result[0].status == "valid"
def test_dashboard_url_sources(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
svc._parse_superset_link = MagicMock(return_value={
"dashboard_id": "42", "dataset_id": "ds-1",
"partial_recovery": False, "source_url": "http://superset/d/42",
})
svc._resolve_dashboard_title = MagicMock(return_value="Sales Dashboard")
from src.schemas.validation import SourceInput
sources_input = [SourceInput(type="dashboard_url", value="http://superset/d/42")]
result = svc._resolve_sources(sources_input, None, "pol-1", "env-1")
assert len(result) == 1
assert result[0].type == "dashboard_url"
assert result[0].resolved_dashboard_id == "42"
def test_partial_recovery_status(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
svc._parse_superset_link = MagicMock(return_value={
"dashboard_id": "42", "partial_recovery": True,
})
svc._resolve_dashboard_title = MagicMock(return_value=None)
from src.schemas.validation import SourceInput
sources_input = [SourceInput(type="dashboard_url", value="http://superset/d/42")]
result = svc._resolve_sources(sources_input, None, "pol-1", "env-1")
assert result[0].status == "partial_recovery"
def test_dashboard_id_type_source_input(self):
"""Cover _resolve_sources line 199-200: s.type == 'dashboard_id'."""
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
svc._resolve_dashboard_title = MagicMock(return_value="Dashboard 42")
from src.schemas.validation import SourceInput
sources_input = [SourceInput(type="dashboard_id", value="42")]
result = svc._resolve_sources(sources_input, None, "pol-1", "env-1")
assert len(result) == 1
assert result[0].type == "dashboard_id"
assert result[0].resolved_dashboard_id == "42"
assert result[0].status == "valid"
def test_policy_to_response_with_sources(self):
"""Cover _policy_to_response lines 242-243: iterating sources."""
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
# Create mock policy with sources
policy_mock = MagicMock()
policy_mock.id = "pol-1"
policy_mock.name = "Test"
policy_mock.description = None
policy_mock.environment_id = "env-1"
policy_mock.is_active = True
policy_mock.dashboard_ids = []
policy_mock.provider_id = "prov-1"
policy_mock.schedule_days = []
policy_mock.window_start = None
policy_mock.window_end = None
policy_mock.notify_owners = None
policy_mock.custom_channels = None
policy_mock.alert_condition = "FAIL_ONLY"
policy_mock.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy_mock.updated_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy_mock.screenshot_enabled = True
policy_mock.logs_enabled = True
policy_mock.execute_chart_data = False
policy_mock.llm_batch_size = 1
policy_mock.policy_dashboard_concurrency_limit = 3
policy_mock.prompt_template = None
source = MagicMock()
source.id = "src-1"
source.type = "dashboard_id"
source.value = "42"
source.status = "valid"
source.title = "Sales Dashboard"
source.resolved_dashboard_id = "42"
source.last_error = None
source.last_checked_at = None
source.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy_mock.sources = [source]
# Mock last_run query
db = MagicMock()
db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None
svc.db = db
result = svc._policy_to_response(policy_mock)
assert result.id == "pol-1"
assert len(result.sources) == 1
def test_record_to_dict_title_fallback(self):
"""Cover _record_to_dict lines 346-350: resolve title when source has no title."""
from src.services.validation_service import ValidationTaskService
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)
# Source exists but without title → triggers fallback
source = MagicMock()
source.title = None
source.resolved_dashboard_id = "42"
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = source
svc = ValidationTaskService(db)
svc._resolve_dashboard_title = MagicMock(return_value="Resolved Dashboard")
result = svc._record_to_dict(record)
assert result["dashboard_title"] == "Resolved Dashboard"
svc._resolve_dashboard_title.assert_called_once_with("env-1", "42")
class TestApplyTimeField:
"""_apply_time_field — parse and set time fields."""
def test_field_not_in_update(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
policy = MagicMock()
svc._apply_time_field({}, policy, "window_start")
def test_field_in_update(self):
from src.services.validation_service import ValidationTaskService
svc = ValidationTaskService(MagicMock())
policy = MagicMock()
svc._apply_time_field({"window_start": "10:00"}, policy, "window_start")
assert policy.window_start == time(10, 0)
# #endregion Test.ValidationTaskService.Helpers

View File

@@ -0,0 +1,318 @@
# #region Test.ValidationTaskService.Triggers [C:3] [TYPE Module] [SEMANTICS test,validation,trigger,run,detail]
# @BRIEF Tests for validation_service.py — trigger runs, list all runs, run detail.
# @RELATION BINDS_TO -> [ValidationService]
from pathlib import Path
import sys
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from unittest.mock import AsyncMock, MagicMock, patch
from datetime import UTC, datetime
import pytest
def make_policy(**overrides):
policy = MagicMock()
policy.id = "pol-1"
policy.name = "Test Task"
policy.description = None
policy.environment_id = "env-1"
policy.is_active = True
policy.dashboard_ids = []
policy.provider_id = "prov-1"
policy.schedule_days = None
policy.window_start = None
policy.window_end = None
policy.notify_owners = None
policy.custom_channels = None
policy.alert_condition = None
policy.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy.updated_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
policy.sources = []
policy.screenshot_enabled = True
policy.logs_enabled = True
policy.execute_chart_data = False
policy.llm_batch_size = 1
policy.policy_dashboard_concurrency_limit = 3
policy.prompt_template = None
for k, v in overrides.items():
setattr(policy, k, v)
return policy
def make_valid_query_mock(policies=None):
m = MagicMock()
policies = policies or []
first_result = policies[0] if policies else None
m.configure_mock(**{"count.return_value": len(policies)})
m.configure_mock(**{"order_by.return_value.offset.return_value.limit.return_value.all.return_value": policies})
m.configure_mock(**{"filter.return_value.first.return_value": first_result})
m.configure_mock(**{"filter.return_value.count.return_value": len(policies)})
m.configure_mock(**{"filter.return_value.order_by.return_value.first.return_value": None})
m.configure_mock(**{"filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value": policies})
return m
class TestTriggerRun:
"""trigger_run — create run and spawn task."""
@pytest.mark.asyncio
async def test_not_found(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
with pytest.raises(ValueError, match="not found"):
await svc.trigger_run("nonexistent", AsyncMock())
@pytest.mark.asyncio
async def test_existing_running_run(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy(dashboard_ids=["42", "99"])
db = MagicMock()
mock_q = MagicMock()
mock_q.filter.return_value.first.side_effect = [policy, MagicMock()]
db.query.return_value = mock_q
svc = ValidationTaskService(db)
with pytest.raises(ValueError, match="already has a running run"):
await svc.trigger_run("pol-1", AsyncMock())
@pytest.mark.asyncio
async def test_worker_limit_reached(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy(dashboard_ids=["42"], screenshot_enabled=True)
db = MagicMock()
call_log = {"count": 0}
def query_fn(model):
if model.__name__ == "ValidationPolicy":
m = MagicMock()
m.filter.return_value.first.return_value = policy
return m
elif model.__name__ == "ValidationRun":
m = MagicMock()
def run_filter(*a, **kw):
call_log["count"] += 1
fm = MagicMock()
if call_log["count"] == 1:
fm.first.return_value = None
else:
fm.count.return_value = 5
return fm
m.filter = run_filter
return m
else:
m = MagicMock()
m.filter.return_value.all.return_value = []
return m
db.query = query_fn
cm = MagicMock()
cm.configure_mock(**{"config.settings.global_validation_worker_limit": 3})
svc = ValidationTaskService(db, config_manager=cm)
with pytest.raises(ValueError, match="worker limit"):
await svc.trigger_run("pol-1", AsyncMock())
@pytest.mark.asyncio
async def test_no_dashboard_ids_raises(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy(dashboard_ids=[], screenshot_enabled=True)
db = MagicMock()
def query_fn(model):
m = MagicMock()
if model.__name__ == "ValidationPolicy":
m.filter.return_value.first.return_value = policy
elif model.__name__ == "ValidationRun":
m.filter.return_value.first.return_value = None
m.filter.return_value.count.return_value = 0
elif model.__name__ == "ValidationSource":
m.filter.return_value.all.return_value = []
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 patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = True
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
with pytest.raises(ValueError, match="no dashboard IDs"):
await svc.trigger_run("pol-1", AsyncMock())
@pytest.mark.asyncio
async def test_successful_trigger(self):
from src.services.validation_service import ValidationTaskService
policy = make_policy(
dashboard_ids=["42", "99"],
screenshot_enabled=True, logs_enabled=True,
execute_chart_data=False, llm_batch_size=1,
policy_dashboard_concurrency_limit=3, prompt_template=None,
)
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, username="tester")
with patch("src.services.validation_service.LLMProviderService") as mock_cls:
mock_svc = MagicMock()
provider = MagicMock()
provider.is_active = True
provider.is_multimodal = True
mock_svc.get_provider.return_value = provider
mock_cls.return_value = mock_svc
task_manager = AsyncMock()
task = MagicMock()
task.id = "spawned-task-1"
task_manager.create_task.return_value = task
result = await svc.trigger_run("pol-1", task_manager)
assert result["task_id"] == "spawned-task-1"
assert "run_id" in result
class TestListAllRuns:
"""list_all_runs — cross-task filtering."""
def make_record(self, **overrides):
from src.models.llm import ValidationRecord as VR
r = MagicMock(spec=VR)
r.id = "rec-1"
r.run_id = "run-1"
r.policy_id = "pol-1"
r.dashboard_id = "42"
r.dashboard_title = "Sales"
r.environment_id = "env-1"
r.status = "PASS"
r.summary = "All good"
r.issues = []
r.raw_response = None
r.screenshot_path = None
r.screenshot_paths = None
r.execution_path = None
r.dataset_health = None
r.chart_data_results = []
r.tab_screenshots = []
r.logs_sent_to_llm = []
r.token_usage = None
r.timings = None
r.timestamp = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
for k, v in overrides.items():
setattr(r, k, v)
return r
def test_no_filters(self):
from src.services.validation_service import ValidationTaskService
record = self.make_record()
db = MagicMock()
m = MagicMock()
m.configure_mock(**{"count.return_value": 1})
m.configure_mock(**{"order_by.return_value.offset.return_value.limit.return_value.all.return_value": [record]})
m.configure_mock(**{"filter.return_value.count.return_value": 1})
m.configure_mock(**{"filter.return_value.order_by.return_value.offset.return_value.limit.return_value.all.return_value": [record]})
db.query.return_value = m
svc = ValidationTaskService(db)
total, records = svc.list_all_runs()
assert total == 1
assert len(records) == 1
assert records[0]["status"] == "PASS"
def test_filter_by_status(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
svc = ValidationTaskService(db)
svc.list_all_runs(status="FAIL")
db.query.return_value.filter.assert_called()
def test_filter_by_environment(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
svc = ValidationTaskService(db)
svc.list_all_runs(environment_id="env-1")
def test_filter_by_dashboard_id(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
svc = ValidationTaskService(db)
svc.list_all_runs(dashboard_id="42")
class TestGetRunDetail:
"""get_run_detail — run + records."""
def test_not_found(self):
from src.services.validation_service import ValidationTaskService
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = None
svc = ValidationTaskService(db)
with pytest.raises(ValueError, match="not found"):
svc.get_run_detail("nonexistent")
def test_success(self):
from src.services.validation_service import ValidationTaskService
run = MagicMock()
run.id = "run-1"
run.policy_id = "pol-1"
run.task_id = "task-1"
run.status = "completed"
run.trigger = "manual"
run.started_at = None
run.finished_at = None
run.dashboard_count = 0
run.pass_count = 0
run.warn_count = 0
run.fail_count = 0
run.unknown_count = 0
run.created_at = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
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.dashboard_title = "Sales"
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)
db = MagicMock()
db.query.return_value.filter.return_value.first.return_value = run
db.query.return_value.filter.return_value.order_by.return_value.all.return_value = [record]
svc = ValidationTaskService(db)
result = svc.get_run_detail("run-1")
assert result["run"]["status"] == "completed"
assert len(result["records"]) == 1
assert result["records"][0]["status"] == "PASS"
# #endregion Test.ValidationTaskService.Triggers