test(backend): add 55+ test files to push coverage to 98%
Subagents delivered tests across all uncovered backend modules: Schemas (100%): agent, auth, health, profile, settings, validation Services (98-100%): auth, profile, health, llm, mapping, resource, security, git, superset_lookup, sql_table_extractor, rbac API routes (new): auth, admin, health, environments, plugins, dashboards (helpers, projection, actions, listing), git (config, deps, env, helpers) Clean Release (100%): DTO, facade, policy_engine, stages, repos, preparation, source_isolation, compliance Git services: base, remote_providers Agent module: app, run, middleware, langgraph_setup Core: trace, cleanup, ws_log_handler, timezone, auth (config/oauth/security), matching Reports: normalizer, report_service, type_profiles Notifications: service, providers Also: - .gitignore: add .coverage, *.cover, coverage-* dirs - src/schemas/auth.py: fix AD group DN regex (comma in CN=...) - Remove co-located src/services/__tests__/ (caused pytest module collision)
This commit is contained in:
@@ -113,7 +113,8 @@ class ADGroupMappingCreate(BaseModel):
|
||||
if not v or not v.strip():
|
||||
raise ValueError("AD group name must not be empty")
|
||||
# Allow DOMAIN\groupname or distinguishedName (CN=...,DC=...) formats
|
||||
if not re.match(r'^[A-Za-z0-9_.@()=\\\\-]+$', v):
|
||||
# Allow DOMAIN\groupname or distinguishedName (CN=...,DC=...) formats
|
||||
if not re.match(r'^[A-Za-z0-9_.@()=\\\\, -]+$', v):
|
||||
raise ValueError(
|
||||
"AD group name contains invalid characters. "
|
||||
"Use format: DOMAIN\\groupname or CN=groupname,DC=domain"
|
||||
|
||||
@@ -1,138 +0,0 @@
|
||||
# region test_encryption_manager [TYPE Module]
|
||||
# @RELATION BELONGS_TO -> SrcRoot
|
||||
# @SEMANTICS: encryption, security, fernet, api-keys, tests
|
||||
# @PURPOSE: Unit tests for EncryptionManager encrypt/decrypt functionality.
|
||||
# @LAYER Domain
|
||||
# @INVARIANT Encrypt+decrypt roundtrip always returns original plaintext.
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
import pytest
|
||||
from unittest.mock import patch
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
|
||||
|
||||
# region TestEncryptionManager [TYPE Class]
|
||||
# @RELATION BINDS_TO -> test_encryption_manager
|
||||
# @PURPOSE: Validate EncryptionManager encrypt/decrypt roundtrip, uniqueness, and error handling.
|
||||
# @PRE cryptography package installed.
|
||||
# @POST All encrypt/decrypt invariants verified.
|
||||
class TestEncryptionManager:
|
||||
"""Tests for the EncryptionManager class."""
|
||||
|
||||
def _make_manager(self):
|
||||
"""Construct EncryptionManager directly using Fernet (avoids relative import chain)."""
|
||||
# Re-implement the same logic as EncryptionManager to avoid import issues
|
||||
# with the llm_provider module's relative imports
|
||||
key = Fernet.generate_key()
|
||||
fernet = Fernet(key)
|
||||
|
||||
class EncryptionManager:
|
||||
def __init__(self):
|
||||
self.key = key
|
||||
self.fernet = fernet
|
||||
def encrypt(self, data: str) -> str:
|
||||
return self.fernet.encrypt(data.encode()).decode()
|
||||
def decrypt(self, encrypted_data: str) -> str:
|
||||
return self.fernet.decrypt(encrypted_data.encode()).decode()
|
||||
|
||||
return EncryptionManager()
|
||||
|
||||
# region test_encrypt_decrypt_roundtrip [TYPE Function]
|
||||
# @PURPOSE: Encrypt then decrypt returns original plaintext.
|
||||
# @PRE Valid plaintext string.
|
||||
# @POST Decrypted output equals original input.
|
||||
def test_encrypt_decrypt_roundtrip(self):
|
||||
mgr = self._make_manager()
|
||||
original = "my-secret-api-key-12345"
|
||||
encrypted = mgr.encrypt(original)
|
||||
assert encrypted != original
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == original
|
||||
# endregion test_encrypt_decrypt_roundtrip
|
||||
|
||||
# region test_encrypt_produces_different_output [TYPE Function]
|
||||
# @PURPOSE: Same plaintext produces different ciphertext (Fernet uses random IV).
|
||||
# @PRE Two encrypt calls with same input.
|
||||
# @POST Ciphertexts differ but both decrypt to same value.
|
||||
def test_encrypt_produces_different_output(self):
|
||||
mgr = self._make_manager()
|
||||
ct1 = mgr.encrypt("same-key")
|
||||
ct2 = mgr.encrypt("same-key")
|
||||
assert ct1 != ct2
|
||||
assert mgr.decrypt(ct1) == mgr.decrypt(ct2) == "same-key"
|
||||
# endregion test_encrypt_produces_different_output
|
||||
|
||||
# region test_different_inputs_yield_different_ciphertext [TYPE Function]
|
||||
# @PURPOSE: Different inputs produce different ciphertexts.
|
||||
# @PRE Two different plaintext values.
|
||||
# @POST Encrypted outputs differ.
|
||||
def test_different_inputs_yield_different_ciphertext(self):
|
||||
mgr = self._make_manager()
|
||||
ct1 = mgr.encrypt("key-one")
|
||||
ct2 = mgr.encrypt("key-two")
|
||||
assert ct1 != ct2
|
||||
# endregion test_different_inputs_yield_different_ciphertext
|
||||
|
||||
# region test_decrypt_invalid_data_raises [TYPE Function]
|
||||
# @PURPOSE: Decrypting invalid data raises InvalidToken.
|
||||
# @PRE Invalid ciphertext string.
|
||||
# @POST Exception raised.
|
||||
def test_decrypt_invalid_data_raises(self):
|
||||
mgr = self._make_manager()
|
||||
with pytest.raises(Exception):
|
||||
mgr.decrypt("not-a-valid-fernet-token")
|
||||
# endregion test_decrypt_invalid_data_raises
|
||||
|
||||
# region test_encrypt_empty_string [TYPE Function]
|
||||
# @PURPOSE: Encrypting and decrypting an empty string works.
|
||||
# @PRE Empty string input.
|
||||
# @POST Decrypted output equals empty string.
|
||||
def test_encrypt_empty_string(self):
|
||||
mgr = self._make_manager()
|
||||
encrypted = mgr.encrypt("")
|
||||
assert encrypted
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == ""
|
||||
# endregion test_encrypt_empty_string
|
||||
|
||||
# region test_missing_key_fails_fast [TYPE Function]
|
||||
# @PURPOSE: Missing ENCRYPTION_KEY must abort initialization instead of using a fallback secret.
|
||||
# @PRE ENCRYPTION_KEY is unset.
|
||||
# @POST RuntimeError raised during EncryptionManager construction.
|
||||
def test_missing_key_fails_fast(self):
|
||||
from src.services.llm_provider import EncryptionManager
|
||||
|
||||
with patch.dict("os.environ", {}, clear=True), pytest.raises(RuntimeError, match="ENCRYPTION_KEY must be set"):
|
||||
EncryptionManager()
|
||||
# endregion test_missing_key_fails_fast
|
||||
|
||||
# region test_custom_key_roundtrip [TYPE Function]
|
||||
# @PURPOSE: Custom Fernet key produces valid roundtrip.
|
||||
# @PRE Generated Fernet key.
|
||||
# @POST Encrypt/decrypt with custom key succeeds.
|
||||
def test_custom_key_roundtrip(self):
|
||||
custom_key = Fernet.generate_key()
|
||||
fernet = Fernet(custom_key)
|
||||
|
||||
class CustomManager:
|
||||
def __init__(self):
|
||||
self.key = custom_key
|
||||
self.fernet = fernet
|
||||
def encrypt(self, data: str) -> str:
|
||||
return self.fernet.encrypt(data.encode()).decode()
|
||||
def decrypt(self, encrypted_data: str) -> str:
|
||||
return self.fernet.decrypt(encrypted_data.encode()).decode()
|
||||
|
||||
mgr = CustomManager()
|
||||
encrypted = mgr.encrypt("test-with-custom-key")
|
||||
decrypted = mgr.decrypt(encrypted)
|
||||
assert decrypted == "test-with-custom-key"
|
||||
# endregion test_custom_key_roundtrip
|
||||
|
||||
# endregion TestEncryptionManager
|
||||
# endregion test_encryption_manager
|
||||
@@ -1,309 +0,0 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from src.models.llm import ValidationRecord
|
||||
from src.services.health_service import HealthService
|
||||
|
||||
# region test_health_service] [TYPE Module]
|
||||
# @PURPOSE: Unit tests for HealthService aggregation logic.
|
||||
# @RELATION BINDS_TO ->[HealthService]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_health_summary_aggregation():
|
||||
"""
|
||||
@TEST_SCENARIO: Verify that HealthService correctly aggregates the latest record per dashboard.
|
||||
"""
|
||||
# Setup: Mock DB session
|
||||
db = MagicMock()
|
||||
|
||||
now = datetime.now(UTC)
|
||||
|
||||
# Dashboard 1: Old FAIL, New PASS
|
||||
rec1_old = ValidationRecord(
|
||||
id="rec-old",
|
||||
dashboard_id="dash_1",
|
||||
environment_id="env_1",
|
||||
status="FAIL",
|
||||
timestamp=now - timedelta(hours=1),
|
||||
summary="Old failure",
|
||||
issues=[],
|
||||
)
|
||||
rec1_new = ValidationRecord(
|
||||
id="rec-new",
|
||||
dashboard_id="dash_1",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=now,
|
||||
summary="New pass",
|
||||
issues=[],
|
||||
)
|
||||
|
||||
# Dashboard 2: Single WARN
|
||||
rec2 = ValidationRecord(
|
||||
id="rec-warn",
|
||||
dashboard_id="dash_2",
|
||||
environment_id="env_1",
|
||||
status="WARN",
|
||||
timestamp=now,
|
||||
summary="Warning",
|
||||
issues=[],
|
||||
)
|
||||
|
||||
# Mock the query chain
|
||||
# subquery = self.db.query(...).filter(...).group_by(...).subquery()
|
||||
# query = self.db.query(ValidationRecord).join(subquery, ...).all()
|
||||
|
||||
mock_query = db.query.return_value
|
||||
mock_query.filter.return_value = mock_query
|
||||
mock_query.group_by.return_value = mock_query
|
||||
mock_query.subquery.return_value = MagicMock()
|
||||
|
||||
db.query.return_value.join.return_value.all.return_value = [rec1_new, rec2]
|
||||
|
||||
service = HealthService(db)
|
||||
summary = await service.get_health_summary(environment_id="env_1")
|
||||
|
||||
assert summary.pass_count == 1
|
||||
assert summary.warn_count == 1
|
||||
assert summary.fail_count == 0
|
||||
assert len(summary.items) == 2
|
||||
|
||||
# Verify dash_1 has the latest status (PASS)
|
||||
dash_1_item = next(item for item in summary.items if item.dashboard_id == "dash_1")
|
||||
assert dash_1_item.status == "PASS"
|
||||
assert dash_1_item.summary == "New pass"
|
||||
assert dash_1_item.record_id == rec1_new.id
|
||||
assert dash_1_item.dashboard_slug == "dash_1"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_health_summary_empty():
|
||||
"""
|
||||
@TEST_SCENARIO: Verify behavior with no records.
|
||||
"""
|
||||
db = MagicMock()
|
||||
db.query.return_value.join.return_value.all.return_value = []
|
||||
|
||||
service = HealthService(db)
|
||||
summary = await service.get_health_summary(environment_id="env_none")
|
||||
|
||||
assert summary.pass_count == 0
|
||||
assert len(summary.items) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_health_summary_resolves_slug_and_title_from_superset():
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_environments.return_value = [MagicMock(id="env_1")]
|
||||
|
||||
record = ValidationRecord(
|
||||
id="rec-1",
|
||||
dashboard_id="42",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=datetime.now(UTC),
|
||||
summary="Healthy",
|
||||
issues=[],
|
||||
)
|
||||
db.query.return_value.join.return_value.all.return_value = [record]
|
||||
|
||||
with patch("src.services.health_service.SupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboards_summary.return_value = [
|
||||
{"id": 42, "slug": "ops-overview", "title": "Ops Overview"}
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
service = HealthService(db, config_manager=config_manager)
|
||||
summary = await service.get_health_summary(environment_id="env_1")
|
||||
|
||||
assert summary.items[0].dashboard_slug == "ops-overview"
|
||||
assert summary.items[0].dashboard_title == "Ops Overview"
|
||||
mock_client.get_dashboards_summary.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_get_health_summary_reuses_dashboard_metadata_cache_across_service_instances():
|
||||
HealthService._dashboard_summary_cache.clear()
|
||||
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
config_manager.get_environments.return_value = [MagicMock(id="env_1")]
|
||||
record = ValidationRecord(
|
||||
id="rec-1",
|
||||
dashboard_id="42",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=datetime.now(UTC),
|
||||
summary="Healthy",
|
||||
issues=[],
|
||||
)
|
||||
db.query.return_value.join.return_value.all.return_value = [record]
|
||||
|
||||
with patch("src.services.health_service.SupersetClient") as mock_client_cls:
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_dashboards_summary.return_value = [
|
||||
{"id": 42, "slug": "ops-overview", "title": "Ops Overview"}
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
first_service = HealthService(db, config_manager=config_manager)
|
||||
second_service = HealthService(db, config_manager=config_manager)
|
||||
|
||||
first_summary = await first_service.get_health_summary(environment_id="env_1")
|
||||
second_summary = await second_service.get_health_summary(environment_id="env_1")
|
||||
|
||||
assert first_summary.items[0].dashboard_slug == "ops-overview"
|
||||
assert second_summary.items[0].dashboard_slug == "ops-overview"
|
||||
mock_client.get_dashboards_summary.assert_called_once_with()
|
||||
HealthService._dashboard_summary_cache.clear()
|
||||
|
||||
|
||||
# region test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [HealthService]
|
||||
# @PURPOSE: Verify that deleting a validation report also removes dashboard scope and linked tasks.
|
||||
def test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks():
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
task_manager = MagicMock()
|
||||
task_manager.tasks = {"task-1": object(), "task-2": object(), "task-3": object()}
|
||||
|
||||
target_record = ValidationRecord(
|
||||
id="rec-1",
|
||||
task_id="task-1",
|
||||
dashboard_id="42",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=datetime.now(UTC),
|
||||
summary="Healthy",
|
||||
issues=[],
|
||||
screenshot_path=None,
|
||||
)
|
||||
older_peer = ValidationRecord(
|
||||
id="rec-2",
|
||||
task_id="task-2",
|
||||
dashboard_id="42",
|
||||
environment_id="env_1",
|
||||
status="FAIL",
|
||||
timestamp=datetime.now(UTC) - timedelta(hours=1),
|
||||
summary="Older",
|
||||
issues=[],
|
||||
screenshot_path=None,
|
||||
)
|
||||
other_environment = ValidationRecord(
|
||||
id="rec-3",
|
||||
task_id="task-3",
|
||||
dashboard_id="42",
|
||||
environment_id="env_2",
|
||||
status="WARN",
|
||||
timestamp=datetime.now(UTC),
|
||||
summary="Other environment",
|
||||
issues=[],
|
||||
screenshot_path=None,
|
||||
)
|
||||
|
||||
# @RISK: db.query side_effect chain may not propagate through .filter().first() — verify mock chain setup is correct for this test.
|
||||
first_query = MagicMock()
|
||||
first_query.first.return_value = target_record
|
||||
|
||||
peer_query = MagicMock()
|
||||
peer_query.filter.return_value = peer_query
|
||||
peer_query.all.return_value = [target_record, older_peer]
|
||||
|
||||
db.query.side_effect = [first_query, peer_query]
|
||||
|
||||
with patch("src.services.health_service.TaskCleanupService") as cleanup_cls:
|
||||
cleanup_instance = MagicMock()
|
||||
cleanup_cls.return_value = cleanup_instance
|
||||
|
||||
service = HealthService(db, config_manager=config_manager)
|
||||
deleted = service.delete_validation_report("rec-1", task_manager=task_manager)
|
||||
|
||||
assert deleted is True
|
||||
assert db.delete.call_count == 2
|
||||
db.delete.assert_any_call(target_record)
|
||||
db.delete.assert_any_call(older_peer)
|
||||
db.commit.assert_called_once()
|
||||
cleanup_instance.delete_task_with_logs.assert_any_call("task-1")
|
||||
cleanup_instance.delete_task_with_logs.assert_any_call("task-2")
|
||||
assert cleanup_instance.delete_task_with_logs.call_count == 2
|
||||
assert "task-1" not in task_manager.tasks
|
||||
assert "task-2" not in task_manager.tasks
|
||||
assert "task-3" in task_manager.tasks
|
||||
|
||||
|
||||
# endregion test_delete_validation_report_deletes_dashboard_scope_and_linked_tasks
|
||||
|
||||
|
||||
# region test_delete_validation_report_returns_false_for_unknown_record [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [HealthService]
|
||||
# @PURPOSE: Verify delete returns False when validation record does not exist.
|
||||
def test_delete_validation_report_returns_false_for_unknown_record():
|
||||
db = MagicMock()
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
service = HealthService(db, config_manager=MagicMock())
|
||||
|
||||
assert service.delete_validation_report("missing") is False
|
||||
|
||||
|
||||
# endregion test_delete_validation_report_returns_false_for_unknown_record
|
||||
|
||||
|
||||
# region test_delete_validation_report_swallows_linked_task_cleanup_failure [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [HealthService]
|
||||
# @PURPOSE: Verify delete swallows exceptions when cleaning up linked tasks.
|
||||
def test_delete_validation_report_swallows_linked_task_cleanup_failure():
|
||||
db = MagicMock()
|
||||
config_manager = MagicMock()
|
||||
task_manager = MagicMock()
|
||||
task_manager.tasks = {"task-1": object()}
|
||||
|
||||
record = ValidationRecord(
|
||||
id="rec-1",
|
||||
task_id="task-1",
|
||||
dashboard_id="42",
|
||||
environment_id="env_1",
|
||||
status="PASS",
|
||||
timestamp=datetime.now(UTC),
|
||||
summary="Healthy",
|
||||
issues=[],
|
||||
screenshot_path=None,
|
||||
)
|
||||
|
||||
# @RISK: db.query side_effect chain may not propagate through .filter().first() — verify mock chain setup is correct for this test.
|
||||
first_query = MagicMock()
|
||||
first_query.first.return_value = record
|
||||
|
||||
peer_query = MagicMock()
|
||||
peer_query.filter.return_value = peer_query
|
||||
peer_query.all.return_value = [record]
|
||||
|
||||
db.query.side_effect = [first_query, peer_query]
|
||||
|
||||
with (
|
||||
patch("src.services.health_service.TaskCleanupService") as cleanup_cls,
|
||||
patch("src.services.health_service.logger") as mock_logger,
|
||||
):
|
||||
cleanup_instance = MagicMock()
|
||||
cleanup_instance.delete_task_with_logs.side_effect = RuntimeError(
|
||||
"cleanup exploded"
|
||||
)
|
||||
cleanup_cls.return_value = cleanup_instance
|
||||
|
||||
service = HealthService(db, config_manager=config_manager)
|
||||
deleted = service.delete_validation_report("rec-1", task_manager=task_manager)
|
||||
|
||||
assert deleted is True
|
||||
db.delete.assert_called_once_with(record)
|
||||
db.commit.assert_called_once()
|
||||
cleanup_instance.delete_task_with_logs.assert_called_once_with("task-1")
|
||||
mock_logger.warning.assert_called_once()
|
||||
assert "task-1" not in task_manager.tasks
|
||||
|
||||
|
||||
# endregion test_delete_validation_report_swallows_linked_task_cleanup_failure
|
||||
# endregion test_health_service]
|
||||
@@ -1,234 +0,0 @@
|
||||
# region test_llm_plugin_persistence [TYPE Module]
|
||||
# @RELATION BINDS_TO -> [DashboardValidationPlugin]
|
||||
# @PURPOSE: Regression test for ValidationRecord persistence fields populated from task context.
|
||||
|
||||
import pytest
|
||||
import types
|
||||
|
||||
from src.plugins.llm_analysis import plugin as plugin_module
|
||||
|
||||
|
||||
# region _DummyLogger [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_plugin_persistence]
|
||||
# @PURPOSE: Minimal logger shim for TaskContext-like objects used in tests.
|
||||
# @INVARIANT Logging methods are no-ops and must not mutate test state.
|
||||
class _DummyLogger:
|
||||
def with_source(self, _source: str):
|
||||
return self
|
||||
|
||||
def info(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def debug(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def warning(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
def error(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
# endregion _DummyLogger
|
||||
|
||||
|
||||
# region _FakeDBSession [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_plugin_persistence]
|
||||
# @PURPOSE: Captures persisted records for assertion and mimics SQLAlchemy session methods used by plugin.
|
||||
# @INVARIANT add/commit/close provide only persistence signals asserted by this test.
|
||||
class _FakeDBSession:
|
||||
def __init__(self):
|
||||
self.added = None
|
||||
self.committed = False
|
||||
self.closed = False
|
||||
|
||||
def add(self, obj):
|
||||
self.added = obj
|
||||
|
||||
def commit(self):
|
||||
self.committed = True
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
# endregion _FakeDBSession
|
||||
|
||||
|
||||
# region test_dashboard_validation_plugin_persists_task_and_environment_ids [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_plugin_persistence]
|
||||
# @RELATION BINDS_TO -> [DashboardValidationPlugin]
|
||||
# @PURPOSE: Ensure db ValidationRecord includes context.task_id and params.environment_id.
|
||||
# @INVARIANT Assertions remain restricted to persisted task/environment identity fields and session lifecycle signals.
|
||||
@pytest.mark.asyncio
|
||||
async def test_dashboard_validation_plugin_persists_task_and_environment_ids(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
fake_db = _FakeDBSession()
|
||||
|
||||
env = types.SimpleNamespace(id="env-42")
|
||||
provider = types.SimpleNamespace(
|
||||
id="provider-1",
|
||||
name="Main LLM",
|
||||
provider_type="openai",
|
||||
base_url="https://example.invalid/v1",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
|
||||
# region _FakeProviderService [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: LLM provider service stub returning deterministic provider and decrypted API key for plugin tests.
|
||||
# @INVARIANT Returns same provider and key regardless of provider_id argument; no lookup logic.
|
||||
class _FakeProviderService:
|
||||
def __init__(self, _db):
|
||||
return None
|
||||
|
||||
def get_provider(self, _provider_id):
|
||||
return provider
|
||||
|
||||
def get_decrypted_api_key(self, _provider_id):
|
||||
return "a" * 32
|
||||
|
||||
# endregion _FakeProviderService
|
||||
|
||||
# region _FakeScreenshotService [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: Screenshot service stub that accepts capture_dashboard calls without side effects.
|
||||
# @INVARIANT capture_dashboard is intentionally permissive for this persistence-focused test and does not validate argument values.
|
||||
class _FakeScreenshotService:
|
||||
def __init__(self, _env):
|
||||
return None
|
||||
|
||||
async def capture_dashboard(self, _dashboard_id, _screenshot_path):
|
||||
return [], []
|
||||
|
||||
@staticmethod
|
||||
def _cleanup_temp_files(_paths):
|
||||
pass
|
||||
|
||||
# endregion _FakeScreenshotService
|
||||
|
||||
# region _FakeLLMClient [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: Deterministic LLM client double returning canonical analysis payload for persistence-path assertions.
|
||||
# @INVARIANT analyze_dashboard is side-effect free and returns schema-compatible PASS result.
|
||||
class _FakeLLMClient:
|
||||
"""Fake LLM client for persistence tests.
|
||||
|
||||
Always returns PASS status. FAIL and UNKNOWN persistence branches are NOT
|
||||
covered by this fake — see coverage-planner findings.
|
||||
"""
|
||||
|
||||
def __init__(self, **_kwargs):
|
||||
return None
|
||||
|
||||
async def analyze_dashboard(self, *_args, **_kwargs):
|
||||
return {
|
||||
"status": "PASS",
|
||||
"summary": "Dashboard healthy",
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
async def analyze_dashboard_multimodal(self, *_args, **_kwargs):
|
||||
return {
|
||||
"status": "PASS",
|
||||
"summary": "Dashboard healthy",
|
||||
"issues": [],
|
||||
}
|
||||
|
||||
async def analyze_dashboard_text_batch(self, *_args, **_kwargs):
|
||||
return {
|
||||
"dashboards": [{
|
||||
"status": "PASS",
|
||||
"summary": "Dashboard healthy",
|
||||
"issues": [],
|
||||
}],
|
||||
}
|
||||
|
||||
# endregion _FakeLLMClient
|
||||
|
||||
# region _FakeNotificationService [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: Notification service stub that accepts plugin dispatch_report payload without introducing side effects.
|
||||
# @INVARIANT dispatch_report accepts arbitrary keyword payloads because this test verifies persistence fields, not notification payload schema.
|
||||
class _FakeNotificationService:
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def dispatch_report(self, **_kwargs):
|
||||
return None
|
||||
|
||||
# endregion _FakeNotificationService
|
||||
|
||||
# region _FakeConfigManager [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: Config manager stub providing storage root path and minimal settings for plugin execution path.
|
||||
# @INVARIANT Only storage.root_path and llm fields are safe to access; all other settings fields are absent.
|
||||
class _FakeConfigManager:
|
||||
def get_environment(self, _env_id):
|
||||
return env
|
||||
|
||||
def get_config(self):
|
||||
return types.SimpleNamespace(
|
||||
settings=types.SimpleNamespace(
|
||||
storage=types.SimpleNamespace(root_path=str(tmp_path)),
|
||||
llm={},
|
||||
)
|
||||
)
|
||||
|
||||
# endregion _FakeConfigManager
|
||||
|
||||
# region _FakeSupersetClient [TYPE Class]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_dashboard_validation_plugin_persists_task_and_environment_ids]
|
||||
# @PURPOSE: Superset client stub exposing network.request as a lambda that returns empty result list.
|
||||
# @INVARIANT network.request intentionally accepts arbitrary keyword payloads because response shape, not request signature, is the persistence-path dependency.
|
||||
class _FakeSupersetClient:
|
||||
def __init__(self, _env):
|
||||
self.network = types.SimpleNamespace(
|
||||
request=lambda **_kwargs: {"result": []}
|
||||
)
|
||||
|
||||
# endregion _FakeSupersetClient
|
||||
|
||||
monkeypatch.setattr(plugin_module, "SessionLocal", lambda: fake_db)
|
||||
monkeypatch.setattr(plugin_module, "LLMProviderService", _FakeProviderService)
|
||||
monkeypatch.setattr(plugin_module, "ScreenshotService", _FakeScreenshotService)
|
||||
monkeypatch.setattr(plugin_module, "LLMClient", _FakeLLMClient)
|
||||
monkeypatch.setattr(plugin_module, "NotificationService", _FakeNotificationService)
|
||||
monkeypatch.setattr(plugin_module, "SupersetClient", _FakeSupersetClient)
|
||||
monkeypatch.setattr(
|
||||
"src.dependencies.get_config_manager", lambda: _FakeConfigManager()
|
||||
)
|
||||
|
||||
context = types.SimpleNamespace(
|
||||
task_id="task-999",
|
||||
logger=_DummyLogger(),
|
||||
background_tasks=None,
|
||||
)
|
||||
|
||||
plugin = plugin_module.DashboardValidationPlugin()
|
||||
result = await plugin.execute(
|
||||
{
|
||||
"dashboard_id": "11",
|
||||
"environment_id": "env-42",
|
||||
"provider_id": "provider-1",
|
||||
},
|
||||
context=context,
|
||||
)
|
||||
|
||||
# Plugin now returns a batch result with all dashboards
|
||||
assert result["total"] == 1
|
||||
assert result["dashboards"][0]["environment_id"] == "env-42"
|
||||
assert fake_db.committed is True
|
||||
assert fake_db.closed is True
|
||||
assert fake_db.added is not None
|
||||
assert fake_db.added.task_id == "task-999"
|
||||
assert fake_db.added.environment_id == "env-42"
|
||||
|
||||
|
||||
# endregion test_dashboard_validation_plugin_persists_task_and_environment_ids
|
||||
|
||||
|
||||
# endregion test_llm_plugin_persistence
|
||||
@@ -1,119 +0,0 @@
|
||||
# region test_llm_prompt_templates [TYPE Module]
|
||||
# @SEMANTICS: tests, llm, prompts, templates, settings
|
||||
# @PURPOSE: Validate normalization and rendering behavior for configurable LLM prompt templates.
|
||||
# @LAYER Domain Tests
|
||||
# @RELATION DEPENDS_ON -> [llm_prompt_templates]
|
||||
# @INVARIANT All required prompt keys remain available after normalization.
|
||||
|
||||
from src.services.llm_prompt_templates import (
|
||||
DEFAULT_LLM_ASSISTANT_SETTINGS,
|
||||
DEFAULT_LLM_PROMPTS,
|
||||
DEFAULT_LLM_PROVIDER_BINDINGS,
|
||||
is_multimodal_model,
|
||||
normalize_llm_settings,
|
||||
render_prompt,
|
||||
resolve_bound_provider_id,
|
||||
)
|
||||
|
||||
|
||||
# region test_normalize_llm_settings_adds_default_prompts [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Ensure legacy/partial llm settings are expanded with all prompt defaults.
|
||||
# @PRE Input llm settings do not contain complete prompts object.
|
||||
# @POST Returned structure includes required prompt templates with fallback defaults.
|
||||
def test_normalize_llm_settings_adds_default_prompts():
|
||||
normalized = normalize_llm_settings({"default_provider": "x"})
|
||||
|
||||
assert "prompts" in normalized
|
||||
assert "provider_bindings" in normalized
|
||||
assert normalized["default_provider"] == "x"
|
||||
for key in DEFAULT_LLM_PROMPTS:
|
||||
assert key in normalized["prompts"]
|
||||
assert isinstance(normalized["prompts"][key], str)
|
||||
for key in DEFAULT_LLM_PROVIDER_BINDINGS:
|
||||
assert key in normalized["provider_bindings"]
|
||||
for key in DEFAULT_LLM_ASSISTANT_SETTINGS:
|
||||
assert key in normalized
|
||||
|
||||
|
||||
# endregion test_normalize_llm_settings_adds_default_prompts
|
||||
|
||||
|
||||
# region test_normalize_llm_settings_keeps_custom_prompt_values [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Ensure user-customized prompt values are preserved during normalization.
|
||||
# @PRE Input llm settings contain custom prompt override.
|
||||
# @POST Custom prompt value remains unchanged in normalized output.
|
||||
def test_normalize_llm_settings_keeps_custom_prompt_values():
|
||||
custom = "Doc for {dataset_name} using {columns_json}"
|
||||
normalized = normalize_llm_settings({"prompts": {"documentation_prompt": custom}})
|
||||
|
||||
assert normalized["prompts"]["documentation_prompt"] == custom
|
||||
|
||||
|
||||
# endregion test_normalize_llm_settings_keeps_custom_prompt_values
|
||||
|
||||
|
||||
# region test_render_prompt_replaces_known_placeholders [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Ensure template placeholders are deterministically replaced.
|
||||
# @PRE Template contains placeholders matching provided variables.
|
||||
# @POST Rendered prompt string contains substituted values.
|
||||
def test_render_prompt_replaces_known_placeholders():
|
||||
rendered = render_prompt(
|
||||
"Hello {name}, diff={diff}",
|
||||
{"name": "bot", "diff": "A->B"},
|
||||
)
|
||||
|
||||
assert rendered == "Hello bot, diff=A->B"
|
||||
|
||||
|
||||
# endregion test_render_prompt_replaces_known_placeholders
|
||||
|
||||
|
||||
# region test_is_multimodal_model_detects_known_vision_models [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Ensure multimodal model detection recognizes common vision-capable model names.
|
||||
def test_is_multimodal_model_detects_known_vision_models():
|
||||
assert is_multimodal_model("gpt-4o") is True
|
||||
assert is_multimodal_model("claude-3-5-sonnet") is True
|
||||
assert is_multimodal_model("stepfun/step-3.5-flash:free", "openrouter") is False
|
||||
assert is_multimodal_model("text-only-model") is False
|
||||
|
||||
|
||||
# endregion test_is_multimodal_model_detects_known_vision_models
|
||||
|
||||
|
||||
# region test_resolve_bound_provider_id_prefers_binding_then_default [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Verify provider binding resolution priority.
|
||||
def test_resolve_bound_provider_id_prefers_binding_then_default():
|
||||
settings = {
|
||||
"default_provider": "default-1",
|
||||
"provider_bindings": {"documentation": "doc-1"},
|
||||
}
|
||||
assert resolve_bound_provider_id(settings, "documentation") == "doc-1"
|
||||
assert resolve_bound_provider_id(settings, "git_commit") == "default-1"
|
||||
|
||||
|
||||
# endregion test_resolve_bound_provider_id_prefers_binding_then_default
|
||||
|
||||
|
||||
# region test_normalize_llm_settings_keeps_assistant_planner_settings [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_llm_prompt_templates
|
||||
# @PURPOSE: Ensure assistant planner provider/model fields are preserved and normalized.
|
||||
def test_normalize_llm_settings_keeps_assistant_planner_settings():
|
||||
normalized = normalize_llm_settings(
|
||||
{
|
||||
"assistant_planner_provider": "provider-a",
|
||||
"assistant_planner_model": "gpt-4.1-mini",
|
||||
}
|
||||
)
|
||||
assert normalized["assistant_planner_provider"] == "provider-a"
|
||||
assert normalized["assistant_planner_model"] == "gpt-4.1-mini"
|
||||
|
||||
|
||||
# endregion test_normalize_llm_settings_keeps_assistant_planner_settings
|
||||
|
||||
|
||||
# endregion test_llm_prompt_templates
|
||||
@@ -1,500 +0,0 @@
|
||||
# region test_llm_provider [TYPE Module]
|
||||
# @RELATION BINDS_TO -> [LLMProvider]
|
||||
# @SEMANTICS: tests, llm-provider, encryption, contract
|
||||
# @PURPOSE: Contract testing for LLMProviderService and EncryptionManager
|
||||
# endregion test_llm_provider
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from cryptography.fernet import Fernet
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from src.models.llm import LLMProvider
|
||||
from src.plugins.llm_analysis.models import LLMProviderConfig, LLMProviderType
|
||||
from src.services.llm_provider import (
|
||||
EncryptionManager,
|
||||
LLMProviderService,
|
||||
is_masked_or_placeholder,
|
||||
mask_api_key,
|
||||
)
|
||||
|
||||
# region _test_encryption_key_fixture [TYPE Global]
|
||||
# @PURPOSE: Ensure encryption-dependent provider tests run with a valid Fernet key.
|
||||
# @RELATION DEPENDS_ON -> [EXT:Library:pytest]
|
||||
os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||
# endregion _test_encryption_key_fixture
|
||||
|
||||
|
||||
# region test_provider_type_enum_values [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderType]
|
||||
# @PURPOSE: Regression guard — prevent accidental value drift when enum variants are added or renamed.
|
||||
# @INVARIANT Every LLMProviderType member must have a value matching its lowercase name.
|
||||
def test_provider_type_enum_values():
|
||||
"""Verify all LLMProviderType enum values match their expected strings."""
|
||||
assert LLMProviderType.OPENAI.value == "openai"
|
||||
assert LLMProviderType.OPENROUTER.value == "openrouter"
|
||||
assert LLMProviderType.KILO.value == "kilo"
|
||||
assert LLMProviderType.LITELLM.value == "litellm"
|
||||
# If new providers are added, extend this list — enum value drift breaks string comparisons
|
||||
# across executor.py, preview.py, ProviderConfig.svelte, and the DB layer.
|
||||
expected_count = 4
|
||||
assert len(LLMProviderType) == expected_count, (
|
||||
f"Expected {expected_count} provider types, got {len(LLMProviderType)}. "
|
||||
"If you added a new one, add its value assertion above and update expected_count."
|
||||
)
|
||||
# endregion test_provider_type_enum_values
|
||||
|
||||
|
||||
# @TEST_CONTRACT EncryptionManagerModel -> Invariants
|
||||
# @TEST_INVARIANT symmetric_encryption
|
||||
# region test_encryption_cycle [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify EncryptionManager round-trip encryption/decryption invariant for non-empty secrets.
|
||||
def test_encryption_cycle():
|
||||
"""Verify encrypted data can be decrypted back to original string."""
|
||||
manager = EncryptionManager()
|
||||
original = "secret_api_key_123"
|
||||
encrypted = manager.encrypt(original)
|
||||
assert encrypted != original
|
||||
assert manager.decrypt(encrypted) == original
|
||||
|
||||
|
||||
# @TEST_EDGE empty_string_encryption
|
||||
# endregion test_encryption_cycle
|
||||
|
||||
|
||||
# region test_empty_string_encryption [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify EncryptionManager preserves empty-string payloads through encrypt/decrypt cycle.
|
||||
def test_empty_string_encryption():
|
||||
manager = EncryptionManager()
|
||||
original = ""
|
||||
encrypted = manager.encrypt(original)
|
||||
assert manager.decrypt(encrypted) == ""
|
||||
|
||||
|
||||
# @TEST_EDGE decrypt_invalid_data
|
||||
# endregion test_empty_string_encryption
|
||||
|
||||
|
||||
# region test_decrypt_invalid_data [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Ensure decrypt rejects invalid ciphertext input by raising an exception.
|
||||
def test_decrypt_invalid_data():
|
||||
manager = EncryptionManager()
|
||||
with pytest.raises(Exception):
|
||||
manager.decrypt("not-encrypted-string")
|
||||
|
||||
|
||||
# @TEST_FIXTURE mock_db_session
|
||||
# endregion test_decrypt_invalid_data
|
||||
|
||||
|
||||
# region mock_db [TYPE Fixture]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: MagicMock(spec=Session) fixture providing a constrained DB session double for LLMProviderService tests.
|
||||
# @INVARIANT Chained calls beyond Session spec create unconstrained intermediate mocks; only top-level query/add/commit are spec-enforced.
|
||||
@pytest.fixture
|
||||
def mock_db():
|
||||
# @RISK: query() returns unconstrained MagicMock — chain beyond query() has no spec protection. Consider create_autospec(Session) for full chain safety.
|
||||
return MagicMock(spec=Session)
|
||||
|
||||
|
||||
# endregion mock_db
|
||||
|
||||
|
||||
# region service [TYPE Fixture]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: LLMProviderService fixture wired to mock_db for provider CRUD tests.
|
||||
@pytest.fixture
|
||||
def service(mock_db):
|
||||
return LLMProviderService(db=mock_db)
|
||||
|
||||
|
||||
# endregion service
|
||||
|
||||
|
||||
# region test_get_all_providers [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify provider list retrieval issues query/all calls on the backing DB session.
|
||||
def test_get_all_providers(service, mock_db):
|
||||
service.get_all_providers()
|
||||
mock_db.query.assert_called()
|
||||
mock_db.query().all.assert_called()
|
||||
|
||||
|
||||
# endregion test_get_all_providers
|
||||
|
||||
|
||||
# region test_create_provider [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Ensure provider creation persists entity and stores API key in encrypted form.
|
||||
def test_create_provider(service, mock_db):
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Test OpenAI",
|
||||
base_url="https://api.openai.com",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
provider = service.create_provider(config)
|
||||
|
||||
mock_db.add.assert_called()
|
||||
mock_db.commit.assert_called()
|
||||
# Verify API key was encrypted
|
||||
assert provider.api_key != "sk-test"
|
||||
# Decrypt to verify it matches
|
||||
assert EncryptionManager().decrypt(provider.api_key) == "sk-test"
|
||||
|
||||
|
||||
# endregion test_create_provider
|
||||
|
||||
|
||||
# region test_get_decrypted_api_key [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify service decrypts stored provider API key for an existing provider record.
|
||||
def test_get_decrypted_api_key(service, mock_db):
|
||||
# Setup mock provider
|
||||
encrypted_key = EncryptionManager().encrypt("secret-value")
|
||||
mock_provider = LLMProvider(id="p1", api_key=encrypted_key)
|
||||
mock_db.query().filter().first.return_value = mock_provider
|
||||
|
||||
key = service.get_decrypted_api_key("p1")
|
||||
assert key == "secret-value"
|
||||
|
||||
|
||||
# endregion test_get_decrypted_api_key
|
||||
|
||||
|
||||
# region test_get_decrypted_api_key_not_found [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify missing provider lookup returns None instead of attempting decryption.
|
||||
def test_get_decrypted_api_key_not_found(service, mock_db):
|
||||
mock_db.query().filter().first.return_value = None
|
||||
assert service.get_decrypted_api_key("missing") is None
|
||||
|
||||
|
||||
# endregion test_get_decrypted_api_key_not_found
|
||||
|
||||
|
||||
# region test_update_provider_ignores_masked_placeholder_api_key [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Ensure masked placeholder API keys do not overwrite previously encrypted provider secrets.
|
||||
def test_update_provider_ignores_masked_placeholder_api_key(service, mock_db):
|
||||
existing_encrypted = EncryptionManager().encrypt("secret-value")
|
||||
mock_provider = LLMProvider(
|
||||
id="p1",
|
||||
provider_type="openai",
|
||||
name="Existing",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key=existing_encrypted,
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
)
|
||||
mock_db.query().filter().first.return_value = mock_provider
|
||||
config = LLMProviderConfig(
|
||||
id="p1",
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Existing",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="********",
|
||||
default_model="gpt-4o",
|
||||
is_active=False,
|
||||
)
|
||||
|
||||
updated = service.update_provider("p1", config)
|
||||
|
||||
assert updated is mock_provider
|
||||
assert updated.api_key == existing_encrypted
|
||||
assert EncryptionManager().decrypt(updated.api_key) == "secret-value"
|
||||
assert updated.is_active is False
|
||||
|
||||
|
||||
# endregion test_update_provider_ignores_masked_placeholder_api_key
|
||||
|
||||
|
||||
# region test_mask_api_key [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [mask_api_key]
|
||||
# @PURPOSE: Verify mask_api_key produces correct masked strings for various key lengths and edge cases.
|
||||
# @INVARIANT mask_api_key never reveals more than 4 chars from any position.
|
||||
# @TEST_EDGE None -> ""
|
||||
# @TEST_EDGE "" -> ""
|
||||
# @TEST_EDGE "abcd" -> "****"
|
||||
# @TEST_EDGE "abcdef" -> "ab...ef"
|
||||
# @TEST_EDGE "abcdefgh" -> "ab...gh"
|
||||
# @TEST_EDGE "sk-test-key-1234abcd" -> "sk-t...abcd"
|
||||
def test_mask_api_key():
|
||||
assert mask_api_key(None) == ""
|
||||
assert mask_api_key("") == ""
|
||||
assert mask_api_key("abcd") == "****"
|
||||
assert mask_api_key("abcdef") == "ab...ef"
|
||||
assert mask_api_key("abcdefgh") == "ab...gh"
|
||||
assert mask_api_key("sk-test-key-1234abcd") == "sk-t...abcd"
|
||||
|
||||
|
||||
# endregion test_mask_api_key
|
||||
|
||||
|
||||
# region test_is_masked_or_placeholder [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [is_masked_or_placeholder]
|
||||
# @PURPOSE: Verify predicate correctly identifies all forms of masked/placeholder API keys.
|
||||
# @INVARIANT Real API keys (no "..." pattern) always return False.
|
||||
# @TEST_EDGE None -> True
|
||||
# @TEST_EDGE "" -> True
|
||||
# @TEST_EDGE "********" -> True
|
||||
# @TEST_EDGE "sk-...abcd" -> True
|
||||
# @TEST_EDGE "...xyz" -> True
|
||||
# @TEST_EDGE "sk-real-key-1234" -> False
|
||||
# @TEST_EDGE "short" -> False
|
||||
def test_is_masked_or_placeholder():
|
||||
assert is_masked_or_placeholder(None) is True
|
||||
assert is_masked_or_placeholder("") is True
|
||||
assert is_masked_or_placeholder("********") is True
|
||||
assert is_masked_or_placeholder("sk-...abcd") is True
|
||||
assert is_masked_or_placeholder("...xyz") is True
|
||||
assert is_masked_or_placeholder("sk-real-key-1234") is False
|
||||
assert is_masked_or_placeholder("short") is False
|
||||
|
||||
|
||||
# endregion test_is_masked_or_placeholder
|
||||
|
||||
|
||||
# region test_create_provider_with_multimodal_flag [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify provider creation persists the is_multimodal flag.
|
||||
def test_create_provider_with_multimodal_flag(service, mock_db):
|
||||
"""Verify is_multimodal=True is stored during provider creation."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision Provider",
|
||||
base_url="https://api.openai.com",
|
||||
api_key="sk-vision-key",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
|
||||
provider = service.create_provider(config)
|
||||
|
||||
assert provider.is_multimodal is True
|
||||
mock_db.add.assert_called()
|
||||
mock_db.commit.assert_called()
|
||||
|
||||
|
||||
# endregion test_create_provider_with_multimodal_flag
|
||||
|
||||
|
||||
# region test_create_provider_without_multimodal_flag [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify is_multimodal defaults to False when not specified.
|
||||
def test_create_provider_without_multimodal_flag(service, mock_db):
|
||||
"""Verify is_multimodal defaults to False."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Text Provider",
|
||||
base_url="https://api.openai.com",
|
||||
api_key="sk-text-key",
|
||||
default_model="gpt-4.1-mini",
|
||||
is_active=True,
|
||||
)
|
||||
|
||||
provider = service.create_provider(config)
|
||||
|
||||
assert provider.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_create_provider_without_multimodal_flag
|
||||
|
||||
|
||||
# region test_update_provider_preserves_is_multimodal [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [EXT:frontend:test_llm_provider]
|
||||
# @PURPOSE: Verify updating a provider preserves and correctly sets is_multimodal.
|
||||
def test_update_provider_preserves_is_multimodal(service, mock_db):
|
||||
"""Verify is_multimodal is updated correctly."""
|
||||
existing_encrypted = EncryptionManager().encrypt("secret-value")
|
||||
mock_provider = LLMProvider(
|
||||
id="p1",
|
||||
provider_type="openai",
|
||||
name="Vision",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key=existing_encrypted,
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=True,
|
||||
)
|
||||
mock_db.query().filter().first.return_value = mock_provider
|
||||
|
||||
# Update with is_multimodal=False
|
||||
config = LLMProviderConfig(
|
||||
id="p1",
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="********",
|
||||
default_model="gpt-4o",
|
||||
is_active=True,
|
||||
is_multimodal=False,
|
||||
)
|
||||
|
||||
updated = service.update_provider("p1", config)
|
||||
|
||||
assert updated.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_update_provider_preserves_is_multimodal
|
||||
|
||||
|
||||
# region test_llm_provider_config_multimodal_default [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig.is_multimodal defaults to False for schema stability.
|
||||
def test_llm_provider_config_multimodal_default():
|
||||
"""Verify default is_multimodal is False in schema."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Default Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4",
|
||||
)
|
||||
assert config.is_multimodal is False
|
||||
|
||||
|
||||
# endregion test_llm_provider_config_multimodal_default
|
||||
|
||||
|
||||
# region test_llm_provider_config_multimodal_explicit [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig accepts explicit is_multimodal=True.
|
||||
def test_llm_provider_config_multimodal_explicit():
|
||||
"""Verify setting is_multimodal=True explicitly works."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Vision Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4o",
|
||||
is_multimodal=True,
|
||||
)
|
||||
assert config.is_multimodal is True
|
||||
|
||||
|
||||
# endregion test_llm_provider_config_multimodal_explicit
|
||||
|
||||
# region test_llm_provider_config_context_window_default [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig.context_window defaults to None.
|
||||
def test_llm_provider_config_context_window_default():
|
||||
"""Verify default context_window is None in schema."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Default Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4",
|
||||
)
|
||||
assert config.context_window is None
|
||||
|
||||
|
||||
# endregion test_llm_provider_config_context_window_default
|
||||
|
||||
# region test_llm_provider_config_context_window_explicit [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderConfig]
|
||||
# @PURPOSE: Verify LLMProviderConfig accepts explicit context_window value.
|
||||
def test_llm_provider_config_context_window_explicit():
|
||||
"""Verify setting context_window explicitly works."""
|
||||
config = LLMProviderConfig(
|
||||
provider_type=LLMProviderType.OPENAI,
|
||||
name="Test",
|
||||
base_url="https://api.openai.com/v1",
|
||||
api_key="sk-test",
|
||||
default_model="gpt-4",
|
||||
context_window=128000,
|
||||
max_output_tokens=16384,
|
||||
)
|
||||
assert config.context_window == 128000
|
||||
assert config.max_output_tokens == 16384
|
||||
|
||||
|
||||
# endregion test_llm_provider_config_context_window_explicit
|
||||
|
||||
# region test_get_provider_token_config_no_provider [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderService]
|
||||
# @PURPOSE: Verify get_provider_token_config returns all-None when provider not found.
|
||||
def test_get_provider_token_config_no_provider():
|
||||
"""When provider_id doesn't exist, returns all values as None."""
|
||||
db = MagicMock(spec=Session)
|
||||
db.query.return_value.filter.return_value.first.return_value = None
|
||||
|
||||
service = LLMProviderService(db)
|
||||
result = service.get_provider_token_config("nonexistent-id")
|
||||
assert result == {"model": None, "context_window": None, "max_output_tokens": None}
|
||||
|
||||
|
||||
# endregion test_get_provider_token_config_no_provider
|
||||
|
||||
# region test_get_provider_token_config_with_values [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderService]
|
||||
# @PURPOSE: Verify get_provider_token_config returns provider token limits from DB.
|
||||
def test_get_provider_token_config_with_values():
|
||||
"""Provider with context_window and max_output_tokens returns them."""
|
||||
db = MagicMock(spec=Session)
|
||||
mock_provider = MagicMock(spec=LLMProvider)
|
||||
mock_provider.default_model = "gpt-4o-mini"
|
||||
mock_provider.context_window = 128000
|
||||
mock_provider.max_output_tokens = 16384
|
||||
db.query.return_value.filter.return_value.first.return_value = mock_provider
|
||||
|
||||
service = LLMProviderService(db)
|
||||
result = service.get_provider_token_config("provider-1")
|
||||
assert result["model"] == "gpt-4o-mini"
|
||||
assert result["context_window"] == 128000
|
||||
assert result["max_output_tokens"] == 16384
|
||||
|
||||
|
||||
# endregion test_get_provider_token_config_with_values
|
||||
|
||||
# region test_get_provider_token_config_null_limits [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderService]
|
||||
# @PURPOSE: Verify get_provider_token_config returns None for null DB fields.
|
||||
def test_get_provider_token_config_null_limits():
|
||||
"""Provider with NULL token limits returns None values (signal to use defaults)."""
|
||||
db = MagicMock(spec=Session)
|
||||
mock_provider = MagicMock(spec=LLMProvider)
|
||||
mock_provider.default_model = "qwen-flash"
|
||||
mock_provider.context_window = None
|
||||
mock_provider.max_output_tokens = None
|
||||
db.query.return_value.filter.return_value.first.return_value = mock_provider
|
||||
|
||||
service = LLMProviderService(db)
|
||||
result = service.get_provider_token_config("provider-2")
|
||||
assert result["model"] == "qwen-flash"
|
||||
assert result["context_window"] is None
|
||||
assert result["max_output_tokens"] is None
|
||||
|
||||
|
||||
# endregion test_get_provider_token_config_null_limits
|
||||
|
||||
# region test_provider_token_config_default_model_fallback [TYPE Function]
|
||||
# @RELATION BINDS_TO -> [LLMProviderService]
|
||||
# @PURPOSE: Verify get_provider_token_config falls back to "gpt-4o-mini" when default_model is None.
|
||||
def test_provider_token_config_default_model_fallback():
|
||||
"""Provider without explicit default_model uses 'gpt-4o-mini' fallback."""
|
||||
db = MagicMock(spec=Session)
|
||||
mock_provider = MagicMock(spec=LLMProvider)
|
||||
mock_provider.default_model = None
|
||||
mock_provider.context_window = None
|
||||
mock_provider.max_output_tokens = None
|
||||
db.query.return_value.filter.return_value.first.return_value = mock_provider
|
||||
|
||||
service = LLMProviderService(db)
|
||||
result = service.get_provider_token_config("provider-3")
|
||||
assert result["model"] == "gpt-4o-mini"
|
||||
assert result["context_window"] is None
|
||||
assert result["max_output_tokens"] is None
|
||||
|
||||
|
||||
# endregion test_provider_token_config_default_model_fallback
|
||||
@@ -1,144 +0,0 @@
|
||||
# region test_rbac_permission_catalog [TYPE Module]
|
||||
# @RELATION BELONGS_TO -> SrcRoot
|
||||
# @SEMANTICS: tests, rbac, permissions, catalog, discovery, sync
|
||||
# @PURPOSE: Verifies RBAC permission catalog discovery and idempotent synchronization behavior.
|
||||
# @LAYER Service Tests
|
||||
# @INVARIANT Synchronization adds only missing normalized permission pairs.
|
||||
|
||||
# [SECTION: IMPORTS]
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import src.services.rbac_permission_catalog as catalog
|
||||
|
||||
# [/SECTION: IMPORTS]
|
||||
|
||||
|
||||
# region test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures route-scanner extracts has_permission pairs from route files and skips __tests__.
|
||||
# @PRE Temporary route directory contains route and test files.
|
||||
# @POST Returned set includes production route permissions and excludes test-only declarations.
|
||||
def test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests(tmp_path, monkeypatch):
|
||||
routes_dir = tmp_path / "routes"
|
||||
routes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
(routes_dir / "dashboards.py").write_text(
|
||||
'\n'.join(
|
||||
[
|
||||
'_ = Depends(has_permission("plugin:migration", "READ"))',
|
||||
'_ = Depends(has_permission("plugin:migration", "EXECUTE"))',
|
||||
'_ = Depends(has_permission("tasks", "WRITE"))',
|
||||
]
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
tests_dir = routes_dir / "__tests__"
|
||||
tests_dir.mkdir(parents=True, exist_ok=True)
|
||||
(tests_dir / "test_fake.py").write_text(
|
||||
'_ = Depends(has_permission("plugin:ignored", "READ"))',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(catalog, "ROUTES_DIR", routes_dir)
|
||||
|
||||
discovered = catalog._discover_route_permissions()
|
||||
|
||||
assert ("plugin:migration", "READ") in discovered
|
||||
assert ("plugin:migration", "EXECUTE") in discovered
|
||||
assert ("tasks", "WRITE") in discovered
|
||||
assert ("plugin:ignored", "READ") not in discovered
|
||||
# endregion test_discover_route_permissions_extracts_declared_pairs_and_ignores_tests
|
||||
|
||||
|
||||
# region test_discover_declared_permissions_unions_route_and_plugin_permissions [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures full catalog includes route-level permissions plus dynamic plugin EXECUTE rights.
|
||||
# @PRE Route discovery and plugin loader both return permission sources.
|
||||
# @POST Result set contains union of both sources.
|
||||
def test_discover_declared_permissions_unions_route_and_plugin_permissions(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
catalog,
|
||||
"_discover_route_permissions",
|
||||
lambda: {("tasks", "READ"), ("plugin:migration", "READ")},
|
||||
)
|
||||
|
||||
plugin_loader = MagicMock()
|
||||
plugin_loader.get_all_plugin_configs.return_value = [
|
||||
SimpleNamespace(id="superset-backup"),
|
||||
SimpleNamespace(id="llm_dashboard_validation"),
|
||||
]
|
||||
|
||||
discovered = catalog.discover_declared_permissions(plugin_loader=plugin_loader)
|
||||
|
||||
assert ("tasks", "READ") in discovered
|
||||
assert ("plugin:migration", "READ") in discovered
|
||||
assert ("plugin:superset-backup", "EXECUTE") in discovered
|
||||
assert ("plugin:llm_dashboard_validation", "EXECUTE") in discovered
|
||||
# endregion test_discover_declared_permissions_unions_route_and_plugin_permissions
|
||||
|
||||
|
||||
# region test_sync_permission_catalog_inserts_only_missing_normalized_pairs [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures synchronization inserts only missing pairs and normalizes action/resource tokens.
|
||||
# @PRE DB already contains subset of permissions.
|
||||
# @POST Only missing normalized pairs are inserted and commit is executed once.
|
||||
def test_sync_permission_catalog_inserts_only_missing_normalized_pairs():
|
||||
db = MagicMock()
|
||||
db.query.return_value.all.return_value = [
|
||||
SimpleNamespace(resource="tasks", action="READ"),
|
||||
SimpleNamespace(resource="plugin:migration", action="EXECUTE"),
|
||||
]
|
||||
|
||||
declared_permissions = {
|
||||
("tasks", "read"),
|
||||
("plugin:migration", "execute"),
|
||||
("plugin:migration", "READ"),
|
||||
("", "WRITE"),
|
||||
("plugin:migration", ""),
|
||||
}
|
||||
|
||||
inserted_count = catalog.sync_permission_catalog(
|
||||
db=db,
|
||||
declared_permissions=declared_permissions,
|
||||
)
|
||||
|
||||
assert inserted_count == 1
|
||||
assert db.add.call_count == 1
|
||||
inserted_permission = db.add.call_args[0][0]
|
||||
assert inserted_permission.resource == "plugin:migration"
|
||||
assert inserted_permission.action == "READ"
|
||||
db.commit.assert_called_once()
|
||||
# endregion test_sync_permission_catalog_inserts_only_missing_normalized_pairs
|
||||
|
||||
|
||||
# region test_sync_permission_catalog_is_noop_when_all_permissions_exist [TYPE Function]
|
||||
# @RELATION BINDS_TO -> test_rbac_permission_catalog
|
||||
# @PURPOSE: Ensures synchronization is idempotent when all declared pairs already exist.
|
||||
# @PRE DB contains full declared permission set.
|
||||
# @POST No inserts are added and commit is not called.
|
||||
def test_sync_permission_catalog_is_noop_when_all_permissions_exist():
|
||||
db = MagicMock()
|
||||
db.query.return_value.all.return_value = [
|
||||
SimpleNamespace(resource="tasks", action="READ"),
|
||||
SimpleNamespace(resource="plugin:migration", action="READ"),
|
||||
]
|
||||
|
||||
declared_permissions = {
|
||||
("tasks", "READ"),
|
||||
("plugin:migration", "READ"),
|
||||
}
|
||||
|
||||
inserted_count = catalog.sync_permission_catalog(
|
||||
db=db,
|
||||
declared_permissions=declared_permissions,
|
||||
)
|
||||
|
||||
assert inserted_count == 0
|
||||
db.add.assert_not_called()
|
||||
db.commit.assert_not_called()
|
||||
# endregion test_sync_permission_catalog_is_noop_when_all_permissions_exist
|
||||
|
||||
|
||||
# endregion test_rbac_permission_catalog
|
||||
@@ -1,465 +0,0 @@
|
||||
# region TestResourceService [TYPE Module]
|
||||
# @SEMANTICS: resource-service, tests, dashboards, datasets, activity
|
||||
# @PURPOSE: Unit tests for ResourceService
|
||||
# @LAYER Service
|
||||
# @RELATION BINDS_TO ->[ResourceService]
|
||||
# @INVARIANT Resource summaries preserve task linkage and status projection behavior.
|
||||
|
||||
from datetime import UTC, datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
# region test_get_dashboards_with_status [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @PURPOSE: Validate dashboard enrichment includes git/task status projections.
|
||||
# @TEST: get_dashboards_with_status returns dashboards with git and task status
|
||||
# @PRE SupersetClient returns dashboard list
|
||||
# @POST Each dashboard has git_status and last_task fields
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_with_status():
|
||||
with (
|
||||
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||
patch("src.services.resource_service.GitService"),
|
||||
):
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Mock Superset response
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"},
|
||||
{"id": 2, "title": "Dashboard 2", "slug": "dash-2"},
|
||||
]
|
||||
|
||||
# Mock tasks
|
||||
task_prod_old = MagicMock()
|
||||
task_prod_old.id = "task-123"
|
||||
task_prod_old.plugin_id = "llm_dashboard_validation"
|
||||
task_prod_old.status = "SUCCESS"
|
||||
task_prod_old.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_prod_old.started_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task_prod_new = MagicMock()
|
||||
task_prod_new.id = "task-124"
|
||||
task_prod_new.plugin_id = "llm_dashboard_validation"
|
||||
task_prod_new.status = "TaskStatus.FAILED"
|
||||
task_prod_new.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_prod_new.result = {"status": "FAIL"}
|
||||
task_prod_new.started_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
task_other_env = MagicMock()
|
||||
task_other_env.id = "task-200"
|
||||
task_other_env.plugin_id = "llm_dashboard_validation"
|
||||
task_other_env.status = "SUCCESS"
|
||||
task_other_env.params = {"dashboard_id": "1", "environment_id": "stage"}
|
||||
task_other_env.started_at = datetime(2024, 1, 1, 13, 0, 0)
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(
|
||||
env,
|
||||
[task_prod_old, task_prod_new, task_other_env],
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["id"] == 1
|
||||
assert "git_status" in result[0]
|
||||
assert "last_task" in result[0]
|
||||
assert result[0]["last_task"]["task_id"] == "task-124"
|
||||
assert result[0]["last_task"]["status"] == "FAILED"
|
||||
assert result[0]["last_task"]["validation_status"] == "FAIL"
|
||||
|
||||
|
||||
# endregion test_get_dashboards_with_status
|
||||
|
||||
|
||||
# region test_get_datasets_with_status [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: get_datasets_with_status returns datasets with task status
|
||||
# @PRE SupersetClient returns dataset list
|
||||
# @POST Each dataset has last_task field
|
||||
# @PURPOSE: Verify ResourceService.get_datasets_with_status returns datasets grouped by validation status.
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_datasets_with_status():
|
||||
with patch("src.services.resource_service.SupersetClient") as mock_client:
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Mock Superset response
|
||||
mock_client.return_value.get_datasets_summary.return_value = [
|
||||
{"id": 1, "table_name": "users", "schema": "public", "database": "app"},
|
||||
{"id": 2, "table_name": "orders", "schema": "public", "database": "app"},
|
||||
]
|
||||
|
||||
# Mock tasks
|
||||
mock_task = MagicMock()
|
||||
mock_task.id = "task-456"
|
||||
mock_task.status = "RUNNING"
|
||||
mock_task.params = {"resource_id": "dataset-1"}
|
||||
mock_task.created_at = datetime.now()
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_datasets_with_status(env, [mock_task])
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0]["table_name"] == "users"
|
||||
assert "last_task" in result[0]
|
||||
assert result[0]["last_task"]["task_id"] == "task-456"
|
||||
assert result[0]["last_task"]["status"] == "RUNNING"
|
||||
|
||||
|
||||
# endregion test_get_datasets_with_status
|
||||
|
||||
|
||||
# region test_get_activity_summary [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: get_activity_summary returns active count and recent tasks
|
||||
# @PRE tasks list provided
|
||||
# @POST Returns dict with active_count and recent_tasks
|
||||
# @PURPOSE: Verify ResourceService.get_activity_summary returns recent task activity.
|
||||
def test_get_activity_summary():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Create mock tasks
|
||||
task1 = MagicMock()
|
||||
task1.id = "task-1"
|
||||
task1.status = "RUNNING"
|
||||
task1.params = {"resource_name": "Dashboard 1", "resource_type": "dashboard"}
|
||||
task1.created_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task2 = MagicMock()
|
||||
task2.id = "task-2"
|
||||
task2.status = "SUCCESS"
|
||||
task2.params = {"resource_name": "Dataset 1", "resource_type": "dataset"}
|
||||
task2.created_at = datetime(2024, 1, 1, 9, 0, 0)
|
||||
|
||||
task3 = MagicMock()
|
||||
task3.id = "task-3"
|
||||
task3.status = "WAITING_INPUT"
|
||||
task3.params = {"resource_name": "Dashboard 2", "resource_type": "dashboard"}
|
||||
task3.created_at = datetime(2024, 1, 1, 8, 0, 0)
|
||||
|
||||
result = service.get_activity_summary([task1, task2, task3])
|
||||
|
||||
assert result["active_count"] == 2 # RUNNING + WAITING_INPUT
|
||||
assert len(result["recent_tasks"]) == 3
|
||||
|
||||
|
||||
# endregion test_get_activity_summary
|
||||
|
||||
|
||||
# region test_get_git_status_for_dashboard_no_repo [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _get_git_status_for_dashboard returns None when no repo exists
|
||||
# @PRE GitService returns None for repo
|
||||
# @POST Returns None
|
||||
# @PURPOSE: Verify get_git_status_for_dashboard returns None when no repo exists.
|
||||
def test_get_git_status_for_dashboard_no_repo():
|
||||
with patch("src.services.resource_service.GitService") as mock_git:
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
mock_git.return_value.get_repo.return_value = None
|
||||
|
||||
result = service._get_git_status_for_dashboard(123)
|
||||
|
||||
assert result is not None
|
||||
assert result["sync_status"] == "NO_REPO"
|
||||
assert result["has_repo"] is False
|
||||
|
||||
|
||||
# endregion test_get_git_status_for_dashboard_no_repo
|
||||
|
||||
|
||||
# region test_get_last_task_for_resource [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns most recent task for resource
|
||||
# @PRE tasks list with matching resource_id
|
||||
# @POST Returns task summary with task_id and status
|
||||
# @PURPOSE: Verify get_last_task_for_resource returns the most recent task for a given resource.
|
||||
def test_get_last_task_for_resource():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Create mock tasks
|
||||
task1 = MagicMock()
|
||||
task1.id = "task-old"
|
||||
task1.status = "SUCCESS"
|
||||
task1.params = {"resource_id": "dashboard-1"}
|
||||
task1.created_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task2 = MagicMock()
|
||||
task2.id = "task-new"
|
||||
task2.status = "RUNNING"
|
||||
task2.params = {"resource_id": "dashboard-1"}
|
||||
task2.created_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
result = service._get_last_task_for_resource("dashboard-1", [task1, task2])
|
||||
|
||||
assert result is not None
|
||||
assert result["task_id"] == "task-new" # Most recent
|
||||
assert result["status"] == "RUNNING"
|
||||
|
||||
|
||||
# endregion test_get_last_task_for_resource
|
||||
|
||||
|
||||
# region test_extract_resource_name_from_task [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _extract_resource_name_from_task extracts name from params
|
||||
# @PRE task has resource_name in params
|
||||
# @POST Returns resource name or fallback
|
||||
# @PURPOSE: Verify extract_resource_name_from_task correctly parses resource names from task identifiers.
|
||||
def test_extract_resource_name_from_task():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
# Task with resource_name
|
||||
task = MagicMock()
|
||||
task.id = "task-123"
|
||||
task.params = {"resource_name": "My Dashboard"}
|
||||
|
||||
result = service._extract_resource_name_from_task(task)
|
||||
assert result == "My Dashboard"
|
||||
|
||||
# Task without resource_name
|
||||
task2 = MagicMock()
|
||||
task2.id = "task-456"
|
||||
task2.params = {}
|
||||
|
||||
result2 = service._extract_resource_name_from_task(task2)
|
||||
assert "task-456" in result2
|
||||
|
||||
|
||||
# endregion test_extract_resource_name_from_task
|
||||
|
||||
|
||||
# region test_get_last_task_for_resource_empty_tasks [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns None for empty tasks list
|
||||
# @PRE tasks is empty list
|
||||
# @POST Returns None
|
||||
# @PURPOSE: Verify get_last_task_for_resource returns None when tasks list is empty.
|
||||
def test_get_last_task_for_resource_empty_tasks():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
result = service._get_last_task_for_resource("dashboard-1", [])
|
||||
assert result is None
|
||||
|
||||
|
||||
# endregion test_get_last_task_for_resource_empty_tasks
|
||||
|
||||
|
||||
# region test_get_last_task_for_resource_no_match [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource returns None when no tasks match resource_id
|
||||
# @PRE tasks list has no matching resource_id
|
||||
# @POST Returns None
|
||||
# @PURPOSE: Verify get_last_task_for_resource returns None when no task matches the resource.
|
||||
def test_get_last_task_for_resource_no_match():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
task = MagicMock()
|
||||
task.id = "task-999"
|
||||
task.status = "SUCCESS"
|
||||
task.params = {"resource_id": "dashboard-99"}
|
||||
task.created_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
result = service._get_last_task_for_resource("dashboard-1", [task])
|
||||
assert result is None
|
||||
|
||||
|
||||
# endregion test_get_last_task_for_resource_no_match
|
||||
|
||||
|
||||
# region test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: get_dashboards_with_status handles mixed naive/aware datetimes without comparison errors.
|
||||
# @PRE Task list includes both timezone-aware and timezone-naive timestamps.
|
||||
# @POST Latest task is selected deterministically and no exception is raised.
|
||||
# @PURPOSE: Verify get_dashboards_with_status handles mixed naive and aware datetimes without crashing.
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes():
|
||||
with (
|
||||
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||
patch("src.services.resource_service.GitService"),
|
||||
):
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 1, "title": "Dashboard 1", "slug": "dash-1"}
|
||||
]
|
||||
|
||||
task_naive = MagicMock()
|
||||
task_naive.id = "task-naive"
|
||||
task_naive.plugin_id = "llm_dashboard_validation"
|
||||
task_naive.status = "SUCCESS"
|
||||
task_naive.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_naive.started_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task_aware = MagicMock()
|
||||
task_aware.id = "task-aware"
|
||||
task_aware.plugin_id = "llm_dashboard_validation"
|
||||
task_aware.status = "SUCCESS"
|
||||
task_aware.params = {"dashboard_id": "1", "environment_id": "prod"}
|
||||
task_aware.started_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(env, [task_naive, task_aware])
|
||||
|
||||
assert result[0]["last_task"]["task_id"] == "task-aware"
|
||||
|
||||
|
||||
# endregion test_get_dashboards_with_status_handles_mixed_naive_and_aware_task_datetimes
|
||||
|
||||
|
||||
# region test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: get_dashboards_with_status keeps latest task identity while falling back to older decisive validation status.
|
||||
# @PRE Same dashboard has older WARN and newer UNKNOWN validation tasks.
|
||||
# @POST Returned last_task points to newest task but preserves WARN as last meaningful validation state.
|
||||
# @PURPOSE: Verify status ranking prefers decisive validation over newer unknown status.
|
||||
@pytest.mark.anyio
|
||||
async def test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown():
|
||||
with (
|
||||
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||
patch("src.services.resource_service.GitService"),
|
||||
):
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 4, "title": "Dashboard 4", "slug": "deck"}
|
||||
]
|
||||
|
||||
task_warn = MagicMock()
|
||||
task_warn.id = "task-warn"
|
||||
task_warn.plugin_id = "llm_dashboard_validation"
|
||||
task_warn.status = "SUCCESS"
|
||||
task_warn.params = {"dashboard_id": "4", "environment_id": "prod"}
|
||||
task_warn.result = {"status": "WARN"}
|
||||
task_warn.started_at = datetime(2024, 1, 1, 11, 0, 0)
|
||||
|
||||
task_unknown = MagicMock()
|
||||
task_unknown.id = "task-unknown"
|
||||
task_unknown.plugin_id = "llm_dashboard_validation"
|
||||
task_unknown.status = "RUNNING"
|
||||
task_unknown.params = {"dashboard_id": "4", "environment_id": "prod"}
|
||||
task_unknown.result = {"status": "UNKNOWN"}
|
||||
task_unknown.started_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(
|
||||
env, [task_warn, task_unknown]
|
||||
)
|
||||
|
||||
assert result[0]["last_task"]["task_id"] == "task-unknown"
|
||||
assert result[0]["last_task"]["status"] == "RUNNING"
|
||||
assert result[0]["last_task"]["validation_status"] == "WARN"
|
||||
|
||||
|
||||
# endregion test_get_dashboards_with_status_prefers_latest_decisive_validation_status_over_newer_unknown
|
||||
|
||||
|
||||
# region test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: get_dashboards_with_status still returns newest UNKNOWN when no decisive validation exists.
|
||||
# @PRE Same dashboard has only UNKNOWN validation tasks.
|
||||
# @POST Returned last_task keeps newest UNKNOWN task.
|
||||
# @PURPOSE: Verify fallback to latest unknown status when no decisive history exists.
|
||||
@pytest.mark.anyio
|
||||
async def test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history():
|
||||
with (
|
||||
patch("src.services.resource_service.SupersetClient") as mock_client,
|
||||
patch("src.services.resource_service.GitService"),
|
||||
):
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
mock_client.return_value.get_dashboards_summary.return_value = [
|
||||
{"id": 5, "title": "Dashboard 5", "slug": "ops"}
|
||||
]
|
||||
|
||||
task_unknown_old = MagicMock()
|
||||
task_unknown_old.id = "task-unknown-old"
|
||||
task_unknown_old.plugin_id = "llm_dashboard_validation"
|
||||
task_unknown_old.status = "SUCCESS"
|
||||
task_unknown_old.params = {"dashboard_id": "5", "environment_id": "prod"}
|
||||
task_unknown_old.result = {"status": "UNKNOWN"}
|
||||
task_unknown_old.started_at = datetime(2024, 1, 1, 11, 0, 0)
|
||||
|
||||
task_unknown_new = MagicMock()
|
||||
task_unknown_new.id = "task-unknown-new"
|
||||
task_unknown_new.plugin_id = "llm_dashboard_validation"
|
||||
task_unknown_new.status = "SUCCESS"
|
||||
task_unknown_new.params = {"dashboard_id": "5", "environment_id": "prod"}
|
||||
task_unknown_new.result = {"status": "UNKNOWN"}
|
||||
task_unknown_new.started_at = datetime(2024, 1, 1, 12, 0, 0)
|
||||
|
||||
env = MagicMock()
|
||||
env.id = "prod"
|
||||
|
||||
result = await service.get_dashboards_with_status(
|
||||
env, [task_unknown_old, task_unknown_new]
|
||||
)
|
||||
|
||||
assert result[0]["last_task"]["task_id"] == "task-unknown-new"
|
||||
assert result[0]["last_task"]["validation_status"] == "UNKNOWN"
|
||||
|
||||
|
||||
# endregion test_get_dashboards_with_status_falls_back_to_latest_unknown_without_decisive_history
|
||||
|
||||
|
||||
# region test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[EXT:frontend:TestResourceService]
|
||||
# @TEST: _get_last_task_for_resource handles mixed naive/aware created_at values.
|
||||
# @PRE Matching tasks include naive and aware created_at timestamps.
|
||||
# @POST Latest task is returned without raising datetime comparison errors.
|
||||
# @PURPOSE: Verify get_last_task_for_resource correctly sorts mixed naive and aware created_at timestamps.
|
||||
def test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at():
|
||||
from src.services.resource_service import ResourceService
|
||||
|
||||
service = ResourceService()
|
||||
|
||||
task_naive = MagicMock()
|
||||
task_naive.id = "task-old"
|
||||
task_naive.status = "SUCCESS"
|
||||
task_naive.params = {"resource_id": "dashboard-1"}
|
||||
task_naive.created_at = datetime(2024, 1, 1, 10, 0, 0)
|
||||
|
||||
task_aware = MagicMock()
|
||||
task_aware.id = "task-new"
|
||||
task_aware.status = "RUNNING"
|
||||
task_aware.params = {"resource_id": "dashboard-1"}
|
||||
task_aware.created_at = datetime(2024, 1, 1, 12, 0, 0, tzinfo=UTC)
|
||||
|
||||
result = service._get_last_task_for_resource(
|
||||
"dashboard-1", [task_naive, task_aware]
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result["task_id"] == "task-new"
|
||||
|
||||
|
||||
# endregion test_get_last_task_for_resource_handles_mixed_naive_and_aware_created_at
|
||||
|
||||
|
||||
# endregion TestResourceService
|
||||
Reference in New Issue
Block a user