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:
465
backend/tests/services/test_profile_preference_service.py
Normal file
465
backend/tests/services/test_profile_preference_service.py
Normal 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
|
||||
578
backend/tests/services/test_resource_service.py
Normal file
578
backend/tests/services/test_resource_service.py
Normal 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
|
||||
426
backend/tests/services/test_validation_service_crud.py
Normal file
426
backend/tests/services/test_validation_service_crud.py
Normal 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
|
||||
427
backend/tests/services/test_validation_service_helpers.py
Normal file
427
backend/tests/services/test_validation_service_helpers.py
Normal 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
|
||||
318
backend/tests/services/test_validation_service_triggers.py
Normal file
318
backend/tests/services/test_validation_service_triggers.py
Normal 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
|
||||
Reference in New Issue
Block a user