diff --git a/backend/tests/services/test_profile_preference_service.py b/backend/tests/services/test_profile_preference_service.py new file mode 100644 index 00000000..acceff5e --- /dev/null +++ b/backend/tests/services/test_profile_preference_service.py @@ -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 diff --git a/backend/tests/services/test_resource_service.py b/backend/tests/services/test_resource_service.py new file mode 100644 index 00000000..862f993f --- /dev/null +++ b/backend/tests/services/test_resource_service.py @@ -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 diff --git a/backend/tests/services/test_validation_service_crud.py b/backend/tests/services/test_validation_service_crud.py new file mode 100644 index 00000000..48422f63 --- /dev/null +++ b/backend/tests/services/test_validation_service_crud.py @@ -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 diff --git a/backend/tests/services/test_validation_service_helpers.py b/backend/tests/services/test_validation_service_helpers.py new file mode 100644 index 00000000..830a1f13 --- /dev/null +++ b/backend/tests/services/test_validation_service_helpers.py @@ -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 diff --git a/backend/tests/services/test_validation_service_triggers.py b/backend/tests/services/test_validation_service_triggers.py new file mode 100644 index 00000000..ebf28e0d --- /dev/null +++ b/backend/tests/services/test_validation_service_triggers.py @@ -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