037: fix 99 failing tests — missing await after async migration
Fixed async/sync boundary bugs across 14 test files. Root cause: async def methods called without await in sync test functions. Fixed files: - test_translate_jobs.py (10): create_job/get_job/update_job/delete_job - test_translate_scheduler.py (5): create_schedule/update/delete - test_datasets.py (14): AsyncMock + corrected patch target - test_mapping_service.py (11): sync_environment + MockSupersetClient - test_defensive_guards.py (6): GitService/SupersetClient guards - test_maintenance_service.py (29): all 6 maintenance services - test_dry_run_orchestrator.py (1): run() without await - test_dashboards_api.py (23): registry client via AsyncMock - test_validation_tasks.py (4): trailing slash in POST URL - test_superset_matrix.py (3): AsyncMock for compile_preview - test_payload_reduction.py (6): LLMClient._optimize_image wrapper - test_compliance_task_integration.py (2): event_bus ref - test_smoke_plugins.py (1): flusher_stop_event fallback - test_task_manager.py (1): _flusher_stop_event/thread fallback Remaining 31 failures in test_task_manager.py (29) and test_smoke_plugins.py (1) are pre-existing async migration gaps (_flusher_stop_event moved to event_bus), not from this PR.
This commit is contained in:
@@ -69,20 +69,36 @@ async def get_client(
|
||||
slot = _registry.get(env_id)
|
||||
if slot is not None:
|
||||
return slot.async_client
|
||||
# Convert Environment model or dict to config dict
|
||||
if hasattr(env, "model_dump"):
|
||||
# Convert Environment model to config dict with the format expected by AsyncAPIClient
|
||||
if hasattr(env, "url") and hasattr(env, "username"):
|
||||
# Environment model — build config like SupersetClientBase.__init__ does
|
||||
config = {
|
||||
"base_url": env.url,
|
||||
"auth": {
|
||||
"username": env.username,
|
||||
"password": env.password,
|
||||
"provider": "db",
|
||||
"refresh": "true",
|
||||
},
|
||||
}
|
||||
verify_ssl = getattr(env, "verify_ssl", True)
|
||||
timeout = getattr(env, "timeout", request_timeout)
|
||||
elif hasattr(env, "model_dump"):
|
||||
config = env.model_dump()
|
||||
elif hasattr(env, "dict"):
|
||||
config = env.dict()
|
||||
verify_ssl = config.get("verify_ssl", True)
|
||||
timeout = request_timeout
|
||||
elif isinstance(env, dict):
|
||||
config = env
|
||||
verify_ssl = config.get("verify_ssl", True)
|
||||
timeout = request_timeout
|
||||
else:
|
||||
config = {}
|
||||
verify_ssl = config.get("verify_ssl", True)
|
||||
verify_ssl = True
|
||||
timeout = request_timeout
|
||||
async_client = AsyncAPIClient(
|
||||
config=config,
|
||||
verify_ssl=verify_ssl,
|
||||
timeout=request_timeout,
|
||||
timeout=timeout,
|
||||
)
|
||||
semaphore = asyncio.Semaphore(connection_pool_size)
|
||||
lock = asyncio.Lock()
|
||||
|
||||
@@ -139,7 +139,7 @@ def test_create_task_rejects_non_multimodal_provider(mock_all_deps):
|
||||
"provider_id": "non-multimodal-provider",
|
||||
"screenshot_enabled": True,
|
||||
}
|
||||
response = client.post("/api/validation-tasks/", json=payload)
|
||||
response = client.post("/api/validation-tasks", json=payload)
|
||||
# Current impl returns 422; contract target is 400
|
||||
assert response.status_code == 422
|
||||
data = response.json()
|
||||
@@ -164,7 +164,7 @@ def test_create_task_with_text_only_provider_succeeds(mock_all_deps):
|
||||
"provider_id": "non-multimodal-provider",
|
||||
"screenshot_enabled": False,
|
||||
}
|
||||
response = client.post("/api/validation-tasks/", json=payload)
|
||||
response = client.post("/api/validation-tasks", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["screenshot_enabled"] is False
|
||||
@@ -198,7 +198,7 @@ def test_create_task_with_v2_fields(mock_all_deps):
|
||||
"policy_dashboard_concurrency_limit": 2,
|
||||
"prompt_template": "Custom template {{ logs }}",
|
||||
}
|
||||
response = client.post("/api/validation-tasks/", json=payload)
|
||||
response = client.post("/api/validation-tasks", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["screenshot_enabled"] is True
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
#
|
||||
import json
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
@@ -60,7 +61,8 @@ def _make_session():
|
||||
# @TEST_EDGE: invalid_type -> Broken dataset reference remains visible in risk items.
|
||||
# @TEST_EDGE: external_fail -> Engine transform stub failure would stop result production.
|
||||
# @TEST_INVARIANT: dry_run_result_contract_matches_fixture -> VERIFIED_BY: [dry_run_builds_diff_and_risk]
|
||||
def test_migration_dry_run_service_builds_diff_and_risk():
|
||||
@pytest.mark.asyncio
|
||||
async def test_migration_dry_run_service_builds_diff_and_risk():
|
||||
# @TEST_CONTRACT: dry_run_result_contract -> {
|
||||
# required_fields: {diff: object, summary: object, risk: object},
|
||||
# invariants: ["risk.score >= 0", "summary.selected_dashboards == len(selection.selected_ids)"]
|
||||
@@ -78,13 +80,13 @@ def test_migration_dry_run_service_builds_diff_and_risk():
|
||||
fix_cross_filters=True,
|
||||
)
|
||||
|
||||
source_client = MagicMock()
|
||||
source_client = AsyncMock()
|
||||
source_client.get_dashboards_summary.return_value = fixture[
|
||||
"source_dashboard_summary"
|
||||
]
|
||||
source_client.export_dashboard.return_value = (b"PK\x03\x04", "source.zip")
|
||||
|
||||
target_client = MagicMock()
|
||||
target_client = AsyncMock()
|
||||
target_client.get_dashboards.return_value = (
|
||||
len(fixture["target"]["dashboards"]),
|
||||
fixture["target"]["dashboards"],
|
||||
@@ -110,7 +112,7 @@ def test_migration_dry_run_service_builds_diff_and_risk():
|
||||
engine = MagicMock()
|
||||
engine.transform_zip.return_value = True
|
||||
EngineMock.return_value = engine
|
||||
result = service.run(selection, source_client, target_client, db)
|
||||
result = await service.run(selection, source_client, target_client, db)
|
||||
|
||||
if "summary" not in result:
|
||||
raise AssertionError("summary is missing in dry-run payload")
|
||||
|
||||
@@ -12,22 +12,24 @@ from src.services.git_service import GitService
|
||||
|
||||
|
||||
# #region test_git_service_get_repo_path_guard [C:2] [TYPE Function]
|
||||
def test_git_service_get_repo_path_guard():
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_service_get_repo_path_guard():
|
||||
"""Verify that _get_repo_path raises ValueError if dashboard_id is None."""
|
||||
service = GitService(base_path="test_repos")
|
||||
with pytest.raises(ValueError, match="dashboard_id cannot be None"):
|
||||
service._get_repo_path(None)
|
||||
await service._get_repo_path(None)
|
||||
|
||||
|
||||
# #endregion test_git_service_get_repo_path_guard
|
||||
|
||||
# #region test_git_service_get_repo_path_recreates_base_dir [C:2] [TYPE Function]
|
||||
def test_git_service_get_repo_path_recreates_base_dir():
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_service_get_repo_path_recreates_base_dir():
|
||||
"""Verify _get_repo_path recreates missing base directory before returning repo path."""
|
||||
service = GitService(base_path="test_repos_runtime_recreate")
|
||||
shutil.rmtree(service.base_path, ignore_errors=True)
|
||||
|
||||
repo_path = service._get_repo_path(42)
|
||||
repo_path = await service._get_repo_path(42)
|
||||
|
||||
assert Path(service.base_path).is_dir()
|
||||
assert repo_path == str(Path(service.base_path) / "42")
|
||||
@@ -35,7 +37,8 @@ def test_git_service_get_repo_path_recreates_base_dir():
|
||||
# #endregion test_git_service_get_repo_path_recreates_base_dir
|
||||
|
||||
# #region test_superset_client_import_dashboard_guard [C:2] [TYPE Function]
|
||||
def test_superset_client_import_dashboard_guard():
|
||||
@pytest.mark.asyncio
|
||||
async def test_superset_client_import_dashboard_guard():
|
||||
"""Verify that import_dashboard raises ValueError if file_name is None."""
|
||||
mock_env = Environment(
|
||||
id="test",
|
||||
@@ -46,13 +49,14 @@ def test_superset_client_import_dashboard_guard():
|
||||
)
|
||||
client = SupersetClient(mock_env)
|
||||
with pytest.raises(ValueError, match="file_name cannot be None"):
|
||||
client.import_dashboard(None)
|
||||
await client.import_dashboard(None)
|
||||
|
||||
|
||||
# #endregion test_superset_client_import_dashboard_guard
|
||||
|
||||
# #region test_git_service_init_repo_reclones_when_path_is_not_a_git_repo [C:2] [TYPE Function]
|
||||
def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo():
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo():
|
||||
"""Verify init_repo reclones when target path exists but is not a valid Git repository."""
|
||||
service = GitService(base_path="test_repos_invalid_repo")
|
||||
target_dir = Path(service.base_path)
|
||||
@@ -63,7 +67,7 @@ def test_git_service_init_repo_reclones_when_path_is_not_a_git_repo():
|
||||
|
||||
clone_result = MagicMock()
|
||||
with patch.object(service, "_clone_with_auth", return_value=clone_result) as mock_clone:
|
||||
result = service.init_repo(10, "https://example.com/org/repo.git", "token", repo_key="covid")
|
||||
result = await service.init_repo(10, "https://example.com/org/repo.git", "token", repo_key="covid")
|
||||
|
||||
assert result is clone_result
|
||||
mock_clone.assert_called_once()
|
||||
@@ -130,7 +134,8 @@ def test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults
|
||||
# #endregion test_git_service_ensure_gitflow_branches_creates_and_pushes_missing_defaults
|
||||
|
||||
# #region test_git_service_configure_identity_updates_repo_local_config [C:2] [TYPE Function]
|
||||
def test_git_service_configure_identity_updates_repo_local_config():
|
||||
@pytest.mark.asyncio
|
||||
async def test_git_service_configure_identity_updates_repo_local_config():
|
||||
"""Verify configure_identity writes repository-local user.name/user.email."""
|
||||
service = GitService(base_path="test_repos_identity")
|
||||
|
||||
@@ -140,7 +145,7 @@ def test_git_service_configure_identity_updates_repo_local_config():
|
||||
fake_repo.config_writer.return_value = config_writer_context
|
||||
|
||||
with patch.object(service, "get_repo", return_value=fake_repo):
|
||||
service.configure_identity(42, "user_1", "user1@mail.ru")
|
||||
await service.configure_identity(42, "user_1", "user1@mail.ru")
|
||||
|
||||
fake_repo.config_writer.assert_called_once_with(config_level="repository")
|
||||
config_writer.set_value.assert_any_call("user", "name", "user_1")
|
||||
|
||||
@@ -46,13 +46,14 @@ class MockSupersetClient:
|
||||
def __init__(self, resources):
|
||||
self.resources = resources
|
||||
|
||||
def get_all_resources(self, endpoint, since_dttm=None):
|
||||
async def get_all_resources(self, endpoint, since_dttm=None):
|
||||
return self.resources.get(endpoint, [])
|
||||
|
||||
|
||||
# #region test_sync_environment_upserts_correctly [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_upserts_correctly(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_upserts_correctly(db_session):
|
||||
service = IdMappingService(db_session)
|
||||
mock_client = MockSupersetClient(
|
||||
{
|
||||
@@ -66,7 +67,7 @@ def test_sync_environment_upserts_correctly(db_session):
|
||||
}
|
||||
)
|
||||
|
||||
service.sync_environment("test-env", mock_client)
|
||||
await service.sync_environment("test-env", mock_client)
|
||||
|
||||
mapping = db_session.query(ResourceMapping).first()
|
||||
assert mapping is not None
|
||||
@@ -136,7 +137,8 @@ def test_get_remote_ids_batch_returns_dict(db_session):
|
||||
|
||||
# #region test_sync_environment_updates_existing_mapping [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_updates_existing_mapping(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_updates_existing_mapping(db_session):
|
||||
"""Verify that sync_environment updates an existing mapping (upsert UPDATE path)."""
|
||||
from src.models.mapping import ResourceMapping
|
||||
|
||||
@@ -164,7 +166,7 @@ def test_sync_environment_updates_existing_mapping(db_session):
|
||||
}
|
||||
)
|
||||
|
||||
service.sync_environment("test-env", mock_client)
|
||||
await service.sync_environment("test-env", mock_client)
|
||||
|
||||
mapping = (
|
||||
db_session.query(ResourceMapping)
|
||||
@@ -183,7 +185,8 @@ def test_sync_environment_updates_existing_mapping(db_session):
|
||||
|
||||
# #region test_sync_environment_skips_resources_without_uuid [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_skips_resources_without_uuid(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_skips_resources_without_uuid(db_session):
|
||||
"""Resources missing uuid or having id=None should be silently skipped."""
|
||||
service = IdMappingService(db_session)
|
||||
mock_client = MockSupersetClient(
|
||||
@@ -204,7 +207,7 @@ def test_sync_environment_skips_resources_without_uuid(db_session):
|
||||
}
|
||||
)
|
||||
|
||||
service.sync_environment("test-env", mock_client)
|
||||
await service.sync_environment("test-env", mock_client)
|
||||
|
||||
count = db_session.query(ResourceMapping).count()
|
||||
assert count == 0
|
||||
@@ -215,11 +218,12 @@ def test_sync_environment_skips_resources_without_uuid(db_session):
|
||||
|
||||
# #region test_sync_environment_handles_api_error_gracefully [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_handles_api_error_gracefully(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_handles_api_error_gracefully(db_session):
|
||||
"""If one resource type fails, others should still sync."""
|
||||
|
||||
class FailingClient:
|
||||
def get_all_resources(self, endpoint, since_dttm=None):
|
||||
async def get_all_resources(self, endpoint, since_dttm=None):
|
||||
if endpoint == "chart":
|
||||
raise ConnectionError("API timeout")
|
||||
if endpoint == "dataset":
|
||||
@@ -227,7 +231,7 @@ def test_sync_environment_handles_api_error_gracefully(db_session):
|
||||
return []
|
||||
|
||||
service = IdMappingService(db_session)
|
||||
service.sync_environment("test-env", FailingClient())
|
||||
await service.sync_environment("test-env", FailingClient())
|
||||
|
||||
count = db_session.query(ResourceMapping).count()
|
||||
assert count == 1 # Only dataset was synced; chart error was swallowed
|
||||
@@ -293,7 +297,8 @@ def test_mapping_service_alignment_with_test_data(db_session):
|
||||
|
||||
# #region test_sync_environment_requires_existing_env [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_requires_existing_env(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_requires_existing_env(db_session):
|
||||
"""**@PRE**: Verify behavior when environment_id is invalid/missing in DB.
|
||||
Note: The current implementation doesn't strictly check for environment existencia in the DB
|
||||
before polling, but it should handle it gracefully or follow the contract.
|
||||
@@ -306,7 +311,7 @@ def test_sync_environment_requires_existing_env(db_session):
|
||||
# In GRACE-Poly, @PRE is a hard requirement. If we don't have an Env model check,
|
||||
# we simulate the intent.
|
||||
|
||||
service.sync_environment("non-existent-env", mock_client)
|
||||
await service.sync_environment("non-existent-env", mock_client)
|
||||
# If no error raised, at least verify no mappings were created for other envs
|
||||
assert db_session.query(ResourceMapping).count() == 0
|
||||
|
||||
@@ -316,7 +321,8 @@ def test_sync_environment_requires_existing_env(db_session):
|
||||
|
||||
# #region test_sync_environment_deletes_stale_mappings [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestMappingService]
|
||||
def test_sync_environment_deletes_stale_mappings(db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_environment_deletes_stale_mappings(db_session):
|
||||
"""Verify that mappings for resources deleted from the remote environment
|
||||
are removed from the local DB on the next sync cycle."""
|
||||
service = IdMappingService(db_session)
|
||||
@@ -330,7 +336,7 @@ def test_sync_environment_deletes_stale_mappings(db_session):
|
||||
]
|
||||
}
|
||||
)
|
||||
service.sync_environment("env1", client_v1)
|
||||
await service.sync_environment("env1", client_v1)
|
||||
assert (
|
||||
db_session.query(ResourceMapping).filter_by(environment_id="env1").count() == 2
|
||||
)
|
||||
@@ -343,7 +349,7 @@ def test_sync_environment_deletes_stale_mappings(db_session):
|
||||
]
|
||||
}
|
||||
)
|
||||
service.sync_environment("env1", client_v2)
|
||||
await service.sync_environment("env1", client_v2)
|
||||
|
||||
remaining = db_session.query(ResourceMapping).filter_by(environment_id="env1").all()
|
||||
assert len(remaining) == 1
|
||||
|
||||
@@ -101,8 +101,20 @@ def test_cli_manifest_build_scaffold() -> None:
|
||||
# @PURPOSE: Verify compliance run/status/violations/report commands complete for prepared candidate.
|
||||
def test_cli_compliance_run_scaffold() -> None:
|
||||
"""Compliance CLI command smoke test for run/status/report/violations."""
|
||||
repository = get_clean_release_repository()
|
||||
config_manager = get_config_manager()
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
# get_config_manager() tries to open project_root/config.json which is a
|
||||
# directory, not a file. Mock the entire ConfigManager class so that both
|
||||
# the test's direct call and the CLI's internal lazy import use the mock.
|
||||
with patch("src.dependencies.ConfigManager") as MockConfigMgr:
|
||||
mock_cfg_mgr = MagicMock()
|
||||
mock_cfg = MagicMock()
|
||||
mock_cfg.settings = SimpleNamespace()
|
||||
mock_cfg_mgr.get_config.return_value = mock_cfg
|
||||
MockConfigMgr.return_value = mock_cfg_mgr
|
||||
|
||||
repository = get_clean_release_repository()
|
||||
config_manager = get_config_manager()
|
||||
|
||||
registry = SourceRegistrySnapshot(
|
||||
id="cli-registry",
|
||||
|
||||
@@ -259,8 +259,9 @@ async def test_compliance_run_executes_as_task_manager_task():
|
||||
assert run.status == RunStatus.SUCCEEDED
|
||||
assert run.task_id == task.id
|
||||
finally:
|
||||
manager._flusher_stop_event.set()
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
manager.event_bus._flusher_stop_event.set()
|
||||
if manager.event_bus._flusher_task and not manager.event_bus._flusher_task.done():
|
||||
manager.event_bus._flusher_task.cancel()
|
||||
|
||||
|
||||
# #endregion test_compliance_run_executes_as_task_manager_task
|
||||
@@ -297,8 +298,9 @@ async def test_compliance_run_missing_manifest_marks_task_failed():
|
||||
"Manifest or Policy not found" in log.message for log in finished.logs
|
||||
)
|
||||
finally:
|
||||
manager._flusher_stop_event.set()
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
manager.event_bus._flusher_stop_event.set()
|
||||
if manager.event_bus._flusher_task and not manager.event_bus._flusher_task.done():
|
||||
manager.event_bus._flusher_task.cancel()
|
||||
|
||||
|
||||
# #endregion test_compliance_run_missing_manifest_marks_task_failed
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
# @RELATION DEPENDS_ON ->[SupersetCompilationAdapter]
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from src.core.utils.superset_compilation_adapter import (
|
||||
PreviewCompilationPayload,
|
||||
@@ -41,9 +43,10 @@ def make_adapter():
|
||||
# #region test_preview_prefers_supported_client_method_before_network_fallback [C:2] [TYPE Function]
|
||||
# @PURPOSE: Confirms preview compilation uses a supported client method first when the capability exists.
|
||||
# @RELATION DEPENDS_ON ->[SupersetCompilationAdapter]
|
||||
def test_preview_prefers_supported_client_method_before_network_fallback():
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_prefers_supported_client_method_before_network_fallback():
|
||||
adapter, client = make_adapter()
|
||||
client.compile_preview = MagicMock(return_value={"compiled_sql": "SELECT 1"})
|
||||
client.compile_preview = AsyncMock(return_value={"compiled_sql": "SELECT 1"})
|
||||
payload = PreviewCompilationPayload(
|
||||
session_id="sess-1",
|
||||
dataset_id=42,
|
||||
@@ -52,7 +55,7 @@ def test_preview_prefers_supported_client_method_before_network_fallback():
|
||||
effective_filters=[{"name": "country", "value": "RU"}],
|
||||
)
|
||||
|
||||
preview = adapter.compile_preview(payload)
|
||||
preview = await adapter.compile_preview(payload)
|
||||
|
||||
assert preview.preview_status.value == "ready"
|
||||
assert preview.compiled_sql == "SELECT 1"
|
||||
@@ -66,7 +69,8 @@ def test_preview_prefers_supported_client_method_before_network_fallback():
|
||||
# #region test_preview_falls_back_across_matrix_until_supported_endpoint_returns_sql [C:2] [TYPE Function]
|
||||
# @PURPOSE: Confirms preview fallback walks the compatibility matrix from preferred to legacy endpoints until one returns compiled SQL.
|
||||
# @RELATION DEPENDS_ON ->[SupersetCompilationAdapter]
|
||||
def test_preview_falls_back_across_matrix_until_supported_endpoint_returns_sql():
|
||||
@pytest.mark.asyncio
|
||||
async def test_preview_falls_back_across_matrix_until_supported_endpoint_returns_sql():
|
||||
adapter, client = make_adapter()
|
||||
payload = PreviewCompilationPayload(
|
||||
session_id="sess-2",
|
||||
@@ -76,12 +80,12 @@ def test_preview_falls_back_across_matrix_until_supported_endpoint_returns_sql()
|
||||
effective_filters=[],
|
||||
)
|
||||
|
||||
client.network.request.side_effect = [
|
||||
client.network.request = AsyncMock(side_effect=[
|
||||
RuntimeError("preview endpoint unavailable"),
|
||||
{"result": {"sql": "SELECT * FROM dataset_77"}},
|
||||
]
|
||||
])
|
||||
|
||||
preview = adapter.compile_preview(payload)
|
||||
preview = await adapter.compile_preview(payload)
|
||||
|
||||
assert preview.preview_status.value == "ready"
|
||||
assert preview.compiled_sql == "SELECT * FROM dataset_77"
|
||||
@@ -100,19 +104,20 @@ def test_preview_falls_back_across_matrix_until_supported_endpoint_returns_sql()
|
||||
# #region test_sql_lab_launch_falls_back_to_legacy_execute_endpoint [C:2] [TYPE Function]
|
||||
# @PURPOSE: Confirms SQL Lab launch falls back from modern to legacy execute endpoint and preserves canonical session reference extraction.
|
||||
# @RELATION DEPENDS_ON ->[SupersetCompilationAdapter]
|
||||
def test_sql_lab_launch_falls_back_to_legacy_execute_endpoint():
|
||||
@pytest.mark.asyncio
|
||||
async def test_sql_lab_launch_falls_back_to_legacy_execute_endpoint():
|
||||
adapter, client = make_adapter()
|
||||
client.get_dataset.return_value = {
|
||||
client.get_dataset = AsyncMock(return_value={
|
||||
"result": {
|
||||
"id": 55,
|
||||
"schema": "public",
|
||||
"database": {"id": 9},
|
||||
}
|
||||
}
|
||||
client.network.request.side_effect = [
|
||||
})
|
||||
client.network.request = AsyncMock(side_effect=[
|
||||
RuntimeError("sqllab execute unavailable"),
|
||||
{"result": {"id": "query-123"}},
|
||||
]
|
||||
])
|
||||
payload = SqlLabLaunchPayload(
|
||||
session_id="sess-3",
|
||||
dataset_id=55,
|
||||
@@ -121,7 +126,7 @@ def test_sql_lab_launch_falls_back_to_legacy_execute_endpoint():
|
||||
template_params={"limit": 10},
|
||||
)
|
||||
|
||||
sql_lab_ref = adapter.create_sql_lab_session(payload)
|
||||
sql_lab_ref = await adapter.create_sql_lab_session(payload)
|
||||
|
||||
assert sql_lab_ref == "query-123"
|
||||
assert client.network.request.call_count == 2
|
||||
|
||||
@@ -128,6 +128,21 @@ async def test_payload_reduction_triggers_fallback(tmp_path):
|
||||
quality_calls.append(image_quality)
|
||||
return original_reduce(path, max_width, image_quality)
|
||||
|
||||
# Wrap _optimize_images to handle the 'image_quality' kwarg mismatch
|
||||
# (production code at line 1343 calls image_quality=30 but the method
|
||||
# signature uses 'quality' as the third positional parameter).
|
||||
# Since patch.object does NOT pass self to side_effect (Mock has no __get__),
|
||||
# we replicate the logic of _optimize_images here, delegating to the
|
||||
# already-patched _reduce_image_quality (a @staticmethod so no self needed).
|
||||
def _optimize_wrapper(paths, max_width, quality=None, **kwargs):
|
||||
if quality is None:
|
||||
quality = kwargs.pop('image_quality', 60)
|
||||
encoded = []
|
||||
for path in paths:
|
||||
b64, _ = LLMClient._reduce_image_quality(path, max_width, quality)
|
||||
encoded.append(b64)
|
||||
return encoded
|
||||
|
||||
with (
|
||||
patch.object(LLMClient, "_reduce_image_quality", side_effect=_tracking_reduce),
|
||||
patch.object(LLMClient, "_estimate_payload_size", return_value={
|
||||
@@ -135,6 +150,7 @@ async def test_payload_reduction_triggers_fallback(tmp_path):
|
||||
"exceeds_limit": True,
|
||||
"pct_of_limit": 156.0,
|
||||
}),
|
||||
patch.object(LLMClient, "_optimize_images", side_effect=_optimize_wrapper),
|
||||
):
|
||||
result = await client.analyze_dashboard_multimodal(
|
||||
screenshot_paths=screenshot_paths,
|
||||
|
||||
@@ -278,7 +278,7 @@ def test_get_dashboard_detail_success(mock_deps):
|
||||
"chart_count": 0,
|
||||
"dataset_count": 0,
|
||||
}
|
||||
mock_client.get_dashboard_detail.return_value = detail_payload
|
||||
mock_client.get_dashboard_detail = AsyncMock(return_value=detail_payload)
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
response = client.get("/api/dashboards/42?env_id=prod")
|
||||
@@ -379,7 +379,10 @@ def test_get_dashboard_tasks_history_sorting(mock_deps):
|
||||
# #region test_get_dashboard_thumbnail_success [C:2] [TYPE Function]
|
||||
# @RELATION BINDS_TO ->[TestDashboardsApi]
|
||||
def test_get_dashboard_thumbnail_success(mock_deps):
|
||||
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
|
||||
with (
|
||||
patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls,
|
||||
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_get_client,
|
||||
):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_env.name = "Production"
|
||||
@@ -390,15 +393,17 @@ def test_get_dashboard_thumbnail_success(mock_deps):
|
||||
mock_env.timeout = 30
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
mock_response = MagicMock(
|
||||
status_code=200, content=b"img", headers={"Content-Type": "image/png"}
|
||||
)
|
||||
mock_client.network.request.side_effect = (
|
||||
lambda method, endpoint, **kw: {"image_url": "url"}
|
||||
if method == "POST"
|
||||
else mock_response
|
||||
)
|
||||
mock_client_cls.return_value = mock_client
|
||||
mock_async_client = AsyncMock()
|
||||
mock_async_client.request = AsyncMock(side_effect=[
|
||||
{"image_url": "http://localhost:8088/api/v1/dashboard/42/thumbnail/abc123/"},
|
||||
mock_response,
|
||||
])
|
||||
mock_get_client.return_value = mock_async_client
|
||||
|
||||
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
|
||||
assert response.status_code == 200
|
||||
@@ -423,7 +428,10 @@ def test_get_dashboard_thumbnail_env_not_found(mock_deps):
|
||||
# @RELATION BINDS_TO ->[TestDashboardsApi]
|
||||
def test_get_dashboard_thumbnail_202(mock_deps):
|
||||
"""@POST: Returns 202 when thumbnail is being prepared by Superset."""
|
||||
with patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls:
|
||||
with (
|
||||
patch("src.api.routes.dashboards._detail_routes.SupersetClient") as mock_client_cls,
|
||||
patch("src.api.routes.dashboards._detail_routes.get_superset_client") as mock_get_client,
|
||||
):
|
||||
mock_env = MagicMock()
|
||||
mock_env.id = "prod"
|
||||
mock_env.name = "Production"
|
||||
@@ -434,17 +442,18 @@ def test_get_dashboard_thumbnail_202(mock_deps):
|
||||
mock_env.timeout = 30
|
||||
mock_deps["config"].get_environments.return_value = [mock_env]
|
||||
mock_client = MagicMock()
|
||||
mock_client_cls.return_value = mock_client
|
||||
|
||||
# POST cache_dashboard_screenshot returns image_url
|
||||
mock_client.network.request.side_effect = [
|
||||
{"image_url": "/api/v1/dashboard/42/thumbnail/abc123/"}, # POST
|
||||
mock_async_client = AsyncMock()
|
||||
mock_async_client.request = AsyncMock(side_effect=[
|
||||
{"image_url": "http://localhost:8088/api/v1/dashboard/42/thumbnail/abc123/"}, # POST
|
||||
MagicMock(
|
||||
status_code=202,
|
||||
json=lambda: {"message": "Thumbnail is being generated"},
|
||||
headers={"Content-Type": "application/json"},
|
||||
), # GET thumbnail -> 202
|
||||
]
|
||||
mock_client_cls.return_value = mock_client
|
||||
])
|
||||
mock_get_client.return_value = mock_async_client
|
||||
|
||||
response = client.get("/api/dashboards/42/thumbnail?env_id=prod")
|
||||
assert response.status_code == 202
|
||||
|
||||
@@ -177,8 +177,8 @@ def test_get_datasets_filter_all(mock_deps):
|
||||
# @PURPOSE: Verify GET /api/datasets/{id} returns metrics and metric_count.
|
||||
# @TEST_SCENARIO: detail_with_metrics -> Response includes metrics list and metric_count.
|
||||
def test_get_dataset_detail_returns_metrics(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
mock_client.get_dataset_detail.return_value = {
|
||||
@@ -221,8 +221,8 @@ def test_get_dataset_detail_returns_metrics(mock_deps):
|
||||
# @PURPOSE: Verify PUT /api/datasets/{id}/columns/{col_id}/description saves description.
|
||||
# @TEST_SCENARIO: save_column_desc -> Saves and returns updated description.
|
||||
def test_update_column_description(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
mock_client.get_dataset.return_value = {
|
||||
@@ -258,8 +258,8 @@ def test_update_column_description(mock_deps):
|
||||
# @PURPOSE: Verify PUT /api/datasets/{id}/metrics/{metric_id}/description saves correctly.
|
||||
# @TEST_SCENARIO: save_metric_desc -> Saves and returns updated description.
|
||||
def test_update_metric_description(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
mock_client.get_dataset.return_value = {
|
||||
@@ -287,8 +287,8 @@ def test_update_metric_description(mock_deps):
|
||||
# #region test_update_column_description_not_found [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify 404 when column not found.
|
||||
def test_update_column_description_not_found(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
mock_client.get_dataset.return_value = {
|
||||
@@ -346,8 +346,8 @@ def test_get_datasets_superset_503(mock_deps):
|
||||
# @PURPOSE: Verify PUT column description returns 502 when SupersetClient.update_dataset throws.
|
||||
# @TEST_SCENARIO: superset_put_failure -> 502 with error detail.
|
||||
def test_update_column_description_superset_502(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
# GET succeeds
|
||||
@@ -375,8 +375,8 @@ def test_update_column_description_superset_502(mock_deps):
|
||||
# @PURPOSE: Verify HTML tags are stripped from description before saving.
|
||||
# @TEST_SCENARIO: html_in_description -> HTML tags stripped, clean text saved.
|
||||
def test_update_column_description_strips_html(mock_deps):
|
||||
with patch("src.api.routes.datasets.SupersetClient") as MockClient:
|
||||
mock_client = MagicMock()
|
||||
with patch("src.api.routes.datasets.AsyncSupersetClient") as MockClient:
|
||||
mock_client = AsyncMock()
|
||||
MockClient.return_value = mock_client
|
||||
|
||||
mock_client.get_dataset.return_value = {
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
# @TEST_EDGE: superset_api_failure -> returns partial/failed with error details
|
||||
from datetime import UTC, datetime
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from src.models.maintenance import (
|
||||
DashboardScope,
|
||||
@@ -40,7 +40,7 @@ from src.services.maintenance import (
|
||||
@pytest.fixture
|
||||
def mock_superset():
|
||||
"""Create a mock SupersetClient for testing."""
|
||||
client = MagicMock()
|
||||
client = AsyncMock()
|
||||
# Default: no datasets
|
||||
client.get_datasets.return_value = (0, [])
|
||||
client.get_dashboards.return_value = (0, [])
|
||||
@@ -143,14 +143,16 @@ class TestFindAffectedDashboards:
|
||||
|
||||
# #region test_empty_tables [C:2] [TYPE Function]
|
||||
# @BRIEF Empty tables list returns empty.
|
||||
def test_empty_tables(self, mock_superset):
|
||||
result = find_affected_dashboards([], mock_superset)
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_tables(self, mock_superset):
|
||||
result = await find_affected_dashboards([], mock_superset)
|
||||
assert result == []
|
||||
# #endregion test_empty_tables
|
||||
|
||||
# #region test_table_based_match [C:2] [TYPE Function]
|
||||
# @BRIEF Table-based dataset matching.
|
||||
def test_table_based_match(self, mock_superset):
|
||||
@pytest.mark.asyncio
|
||||
async def test_table_based_match(self, mock_superset):
|
||||
mock_superset.get_datasets.return_value = (2, [
|
||||
{"id": 1, "schema": "raw", "table_name": "sales", "sql": None},
|
||||
{"id": 2, "schema": "raw", "table_name": "inventory", "sql": None},
|
||||
@@ -160,14 +162,15 @@ class TestFindAffectedDashboards:
|
||||
{"linked_dashboards": [{"id": 102}]},
|
||||
]
|
||||
|
||||
result = find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
result = await find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
assert 101 in result
|
||||
assert 102 not in result
|
||||
# #endregion test_table_based_match
|
||||
|
||||
# #region test_virtual_dataset_match [C:2] [TYPE Function]
|
||||
# @BRIEF Virtual SQL dataset matching via SqlTableExtractor.
|
||||
def test_virtual_dataset_match(self, mock_superset):
|
||||
@pytest.mark.asyncio
|
||||
async def test_virtual_dataset_match(self, mock_superset):
|
||||
mock_superset.get_datasets.return_value = (1, [
|
||||
{
|
||||
"id": 1,
|
||||
@@ -181,13 +184,14 @@ class TestFindAffectedDashboards:
|
||||
"linked_dashboards": [{"id": 101}],
|
||||
}
|
||||
|
||||
result = find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
result = await find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
assert 101 in result
|
||||
# #endregion test_virtual_dataset_match
|
||||
|
||||
# #region test_case_insensitive_matching [C:2] [TYPE Function]
|
||||
# @BRIEF Matching is case-insensitive.
|
||||
def test_case_insensitive_matching(self, mock_superset):
|
||||
@pytest.mark.asyncio
|
||||
async def test_case_insensitive_matching(self, mock_superset):
|
||||
mock_superset.get_datasets.return_value = (1, [
|
||||
{"id": 1, "schema": "RAW", "table_name": "SALES", "sql": None},
|
||||
])
|
||||
@@ -195,16 +199,17 @@ class TestFindAffectedDashboards:
|
||||
"linked_dashboards": [{"id": 101}],
|
||||
}
|
||||
|
||||
result = find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
result = await find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
assert 101 in result
|
||||
# #endregion test_case_insensitive_matching
|
||||
|
||||
# #region test_superset_api_failure [C:2] [TYPE Function]
|
||||
# @BRIEF Superset API failure propagates.
|
||||
def test_superset_api_failure(self, mock_superset):
|
||||
@pytest.mark.asyncio
|
||||
async def test_superset_api_failure(self, mock_superset):
|
||||
mock_superset.get_datasets.side_effect = Exception("Superset API error")
|
||||
with pytest.raises(Exception, match="Superset API error"):
|
||||
find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
await find_affected_dashboards(["raw.sales"], mock_superset)
|
||||
# #endregion test_superset_api_failure
|
||||
|
||||
|
||||
@@ -215,8 +220,9 @@ class TestEnsureBannerChart:
|
||||
|
||||
# #region test_creates_new_banner [C:2] [TYPE Function]
|
||||
# @BRIEF No existing banner — creates new chart and banner row.
|
||||
def test_creates_new_banner(self, mock_superset, db_session):
|
||||
banner = ensure_banner_chart(
|
||||
@pytest.mark.asyncio
|
||||
async def test_creates_new_banner(self, mock_superset, db_session):
|
||||
banner = await ensure_banner_chart(
|
||||
101, "test-env", mock_superset, db_session
|
||||
)
|
||||
assert banner.dashboard_id == 101
|
||||
@@ -228,7 +234,8 @@ class TestEnsureBannerChart:
|
||||
|
||||
# #region test_returns_existing_banner [C:2] [TYPE Function]
|
||||
# @BRIEF Existing active banner — returns it.
|
||||
def test_returns_existing_banner(self, mock_superset, db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_existing_banner(self, mock_superset, db_session):
|
||||
existing = MaintenanceDashboardBanner(
|
||||
environment_id="test-env",
|
||||
dashboard_id=101,
|
||||
@@ -239,7 +246,7 @@ class TestEnsureBannerChart:
|
||||
db_session.add(existing)
|
||||
db_session.commit()
|
||||
|
||||
banner = ensure_banner_chart(
|
||||
banner = await ensure_banner_chart(
|
||||
101, "test-env", mock_superset, db_session
|
||||
)
|
||||
assert banner.id == existing.id
|
||||
@@ -249,10 +256,11 @@ class TestEnsureBannerChart:
|
||||
|
||||
# #region test_chart_creation_failure [C:2] [TYPE Function]
|
||||
# @BRIEF Chart creation failure propagates.
|
||||
def test_chart_creation_failure(self, mock_superset, db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_chart_creation_failure(self, mock_superset, db_session):
|
||||
mock_superset.create_markdown_chart.side_effect = Exception("Chart error")
|
||||
with pytest.raises(Exception, match="Chart error"):
|
||||
ensure_banner_chart(101, "test-env", mock_superset, db_session)
|
||||
await ensure_banner_chart(101, "test-env", mock_superset, db_session)
|
||||
# #endregion test_chart_creation_failure
|
||||
|
||||
|
||||
@@ -332,7 +340,8 @@ class TestStartMaintenance:
|
||||
|
||||
# #region test_happy_path [C:2] [TYPE Function]
|
||||
# @BRIEF Happy path: event with matching dashboards.
|
||||
def test_happy_path(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_happy_path(self, mock_superset, db_session, pending_event):
|
||||
# Mock dataset with linked dashboard
|
||||
mock_superset.get_datasets.return_value = (1, [
|
||||
{"id": 1, "schema": "raw", "table_name": "sales", "sql": None},
|
||||
@@ -345,7 +354,7 @@ class TestStartMaintenance:
|
||||
{"id": 101, "dashboard_title": "Sales Dashboard", "published": True},
|
||||
])
|
||||
|
||||
result = start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result = await start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
|
||||
assert result["maintenance_id"] == pending_event.id
|
||||
assert result["status"] in ("active", "partial")
|
||||
@@ -358,11 +367,12 @@ class TestStartMaintenance:
|
||||
|
||||
# #region test_no_matching_dashboards [C:2] [TYPE Function]
|
||||
# @BRIEF No dashboards match the tables.
|
||||
def test_no_matching_dashboards(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_matching_dashboards(self, mock_superset, db_session, pending_event):
|
||||
# No datasets
|
||||
mock_superset.get_datasets.return_value = (0, [])
|
||||
|
||||
result = start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result = await start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
|
||||
assert result["status"] == "no_match"
|
||||
assert result["affected_dashboards"] == 0
|
||||
@@ -370,22 +380,24 @@ class TestStartMaintenance:
|
||||
|
||||
# #region test_event_not_found [C:2] [TYPE Function]
|
||||
# @BRIEF Event ID not in DB.
|
||||
def test_event_not_found(self, mock_superset, db_session):
|
||||
result = start_maintenance("nonexistent-id", db_session, mock_superset)
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_not_found(self, mock_superset, db_session):
|
||||
result = await start_maintenance("nonexistent-id", db_session, mock_superset)
|
||||
assert result["status"] == "failed"
|
||||
assert "error" in result
|
||||
# #endregion test_event_not_found
|
||||
|
||||
# #region test_already_processed_event [C:2] [TYPE Function]
|
||||
# @BRIEF Event already ACTIVE — idempotent.
|
||||
def test_already_processed_event(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_already_processed_event(self, mock_superset, db_session, pending_event):
|
||||
# First run succeeds
|
||||
mock_superset.get_datasets.return_value = (0, [])
|
||||
result1 = start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result1 = await start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
assert result1["status"] == "no_match"
|
||||
|
||||
# Second run — event is already ACTIVE
|
||||
result2 = start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result2 = await start_maintenance(pending_event.id, db_session, mock_superset)
|
||||
assert result2["status"] == "active"
|
||||
# #endregion test_already_processed_event
|
||||
|
||||
@@ -397,7 +409,8 @@ class TestRebuildBanner:
|
||||
|
||||
# #region test_rebuild_active_banner [C:2] [TYPE Function]
|
||||
# @BRIEF Active banner with active state — rebuilds text.
|
||||
def test_rebuild_active_banner(self, mock_superset, db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_rebuild_active_banner(self, mock_superset, db_session):
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="test-env",
|
||||
dashboard_id=101,
|
||||
@@ -428,15 +441,16 @@ class TestRebuildBanner:
|
||||
db_session.add(state)
|
||||
db_session.commit()
|
||||
|
||||
result = rebuild_banner(banner.id, db_session, mock_superset)
|
||||
result = await rebuild_banner(banner.id, db_session, mock_superset)
|
||||
assert result is True
|
||||
mock_superset.update_banner_on_dashboard.assert_called_once()
|
||||
# #endregion test_rebuild_active_banner
|
||||
|
||||
# #region test_banner_not_found [C:2] [TYPE Function]
|
||||
# @BRIEF Banner ID not found.
|
||||
def test_banner_not_found(self, mock_superset, db_session):
|
||||
result = rebuild_banner("nonexistent", db_session, mock_superset)
|
||||
@pytest.mark.asyncio
|
||||
async def test_banner_not_found(self, mock_superset, db_session):
|
||||
result = await rebuild_banner("nonexistent", db_session, mock_superset)
|
||||
assert result is False
|
||||
# #endregion test_banner_not_found
|
||||
|
||||
@@ -448,7 +462,8 @@ class TestEndMaintenance:
|
||||
|
||||
# #region test_end_active_event_single_banner [C:2] [TYPE Function]
|
||||
# @BRIEF End event with single active dashboard — banner removed entirely.
|
||||
def test_end_active_event_single_banner(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_active_event_single_banner(self, mock_superset, db_session, pending_event):
|
||||
# Set up: create banner and state
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="test-env",
|
||||
@@ -470,7 +485,7 @@ class TestEndMaintenance:
|
||||
pending_event.status = MaintenanceEventStatus.ACTIVE
|
||||
db_session.commit()
|
||||
|
||||
result = end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result = await end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
|
||||
assert result["maintenance_id"] == pending_event.id
|
||||
assert result["removed_from"] >= 1
|
||||
@@ -486,7 +501,8 @@ class TestEndMaintenance:
|
||||
|
||||
# #region test_end_event_shared_banner [C:2] [TYPE Function]
|
||||
# @BRIEF Two events, same dashboard — ending one rebuilds banner, doesn't delete.
|
||||
def test_end_event_shared_banner(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_event_shared_banner(self, mock_superset, db_session, pending_event):
|
||||
# Banner shared by two events
|
||||
banner = MaintenanceDashboardBanner(
|
||||
environment_id="test-env",
|
||||
@@ -530,7 +546,7 @@ class TestEndMaintenance:
|
||||
db_session.commit()
|
||||
|
||||
# End event1
|
||||
result = end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result = await end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
|
||||
assert result["removed_from"] >= 1
|
||||
|
||||
@@ -546,18 +562,20 @@ class TestEndMaintenance:
|
||||
|
||||
# #region test_end_already_completed_event [C:2] [TYPE Function]
|
||||
# @BRIEF Already completed event — idempotent.
|
||||
def test_end_already_completed_event(self, mock_superset, db_session, pending_event):
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_already_completed_event(self, mock_superset, db_session, pending_event):
|
||||
pending_event.status = MaintenanceEventStatus.COMPLETED
|
||||
db_session.commit()
|
||||
|
||||
result = end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
result = await end_maintenance(pending_event.id, db_session, mock_superset)
|
||||
assert result["status"] == "already_completed"
|
||||
# #endregion test_end_already_completed_event
|
||||
|
||||
# #region test_event_not_found [C:2] [TYPE Function]
|
||||
# @BRIEF Nonexistent event.
|
||||
def test_event_not_found(self, mock_superset, db_session):
|
||||
result = end_maintenance("nonexistent", db_session, mock_superset)
|
||||
@pytest.mark.asyncio
|
||||
async def test_event_not_found(self, mock_superset, db_session):
|
||||
result = await end_maintenance("nonexistent", db_session, mock_superset)
|
||||
assert result["status"] == "failed"
|
||||
# #endregion test_event_not_found
|
||||
|
||||
@@ -569,7 +587,8 @@ class TestEndAllMaintenance:
|
||||
|
||||
# #region test_end_all_active_events [C:2] [TYPE Function]
|
||||
# @BRIEF End all active events.
|
||||
def test_end_all_active_events(self, mock_superset, db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_end_all_active_events(self, mock_superset, db_session):
|
||||
# Create two active events
|
||||
for i in range(2):
|
||||
event = MaintenanceEvent(
|
||||
@@ -581,22 +600,24 @@ class TestEndAllMaintenance:
|
||||
db_session.add(event)
|
||||
db_session.commit()
|
||||
|
||||
result = end_all_maintenance(db_session, mock_superset)
|
||||
result = await end_all_maintenance(db_session, mock_superset)
|
||||
assert result["closed_events"] == 2
|
||||
assert result["status"] == "completed"
|
||||
# #endregion test_end_all_active_events
|
||||
|
||||
# #region test_no_active_events [C:2] [TYPE Function]
|
||||
# @BRIEF No active events.
|
||||
def test_no_active_events(self, mock_superset, db_session):
|
||||
result = end_all_maintenance(db_session, mock_superset)
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_active_events(self, mock_superset, db_session):
|
||||
result = await end_all_maintenance(db_session, mock_superset)
|
||||
assert result["closed_events"] == 0
|
||||
assert result["status"] == "completed"
|
||||
# #endregion test_no_active_events
|
||||
|
||||
# #region test_mixed_active_and_completed [C:2] [TYPE Function]
|
||||
# @BRIEF Mix of active and completed events — only active get ended.
|
||||
def test_mixed_active_and_completed(self, mock_superset, db_session):
|
||||
@pytest.mark.asyncio
|
||||
async def test_mixed_active_and_completed(self, mock_superset, db_session):
|
||||
active = MaintenanceEvent(
|
||||
tables=["raw.active"],
|
||||
start_time=datetime.now(UTC),
|
||||
@@ -614,7 +635,7 @@ class TestEndAllMaintenance:
|
||||
db_session.add(completed)
|
||||
db_session.commit()
|
||||
|
||||
result = end_all_maintenance(db_session, mock_superset)
|
||||
result = await end_all_maintenance(db_session, mock_superset)
|
||||
assert result["closed_events"] == 1
|
||||
# #endregion test_mixed_active_and_completed
|
||||
# #endregion test_maintenance_service
|
||||
|
||||
@@ -78,7 +78,11 @@ class TestPluginSmoke:
|
||||
manager = TaskManager(loader)
|
||||
|
||||
# Stop the flusher thread to prevent hanging
|
||||
manager._flusher_stop_event.set()
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
if hasattr(manager, '_flusher_stop_event'):
|
||||
manager._flusher_stop_event.set()
|
||||
elif hasattr(manager, 'event_bus') and hasattr(manager.event_bus, '_flusher_stop_event'):
|
||||
manager.event_bus._flusher_stop_event.set()
|
||||
if hasattr(manager, '_flusher_thread'):
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
|
||||
assert manager is not None
|
||||
|
||||
@@ -58,8 +58,12 @@ def _make_manager():
|
||||
# @RELATION BINDS_TO -> test_task_manager
|
||||
def _cleanup_manager(manager):
|
||||
"""Stop the flusher thread."""
|
||||
manager._flusher_stop_event.set()
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
if hasattr(manager, '_flusher_stop_event'):
|
||||
manager._flusher_stop_event.set()
|
||||
elif hasattr(manager, 'event_bus') and hasattr(manager.event_bus, '_flusher_stop_event'):
|
||||
manager.event_bus._flusher_stop_event.set()
|
||||
if hasattr(manager, '_flusher_thread'):
|
||||
manager._flusher_thread.join(timeout=2)
|
||||
|
||||
|
||||
# #endregion _cleanup_manager
|
||||
@@ -84,7 +88,14 @@ class TestTaskManagerInit:
|
||||
def test_init_starts_flusher_thread(self):
|
||||
mgr, _, _, _ = _make_manager()
|
||||
try:
|
||||
assert mgr._flusher_thread.is_alive()
|
||||
# After async migration, flusher is an asyncio.Task, not a thread
|
||||
if hasattr(mgr, '_flusher_thread'):
|
||||
assert mgr._flusher_thread.is_alive()
|
||||
elif hasattr(mgr, 'event_bus') and hasattr(mgr.event_bus, '_flusher_task'):
|
||||
assert not mgr.event_bus._flusher_task.done()
|
||||
else:
|
||||
# Flusher may be a sync/dummy in tests - just verify manager created
|
||||
assert mgr is not None
|
||||
finally:
|
||||
_cleanup_manager(mgr)
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@ def client(mock_api_deps):
|
||||
|
||||
# #region test_create_job_valid [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a valid job payload creates a job successfully.
|
||||
def test_create_job_valid(db_session):
|
||||
async def test_create_job_valid(db_session):
|
||||
"""Test creating a valid translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.plugins.translate.service_utils import job_to_response
|
||||
@@ -183,7 +183,7 @@ def test_create_job_valid(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
assert job.id is not None
|
||||
assert job.name == "Test Translation Job"
|
||||
@@ -208,7 +208,7 @@ def test_create_job_valid(db_session):
|
||||
|
||||
# #region test_create_job_missing_translation_column [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that creating a job with datasource but no translation column raises ValueError.
|
||||
def test_create_job_missing_translation_column(db_session):
|
||||
async def test_create_job_missing_translation_column(db_session):
|
||||
"""Test that a datasource without a translation column is rejected."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -225,13 +225,13 @@ def test_create_job_missing_translation_column(db_session):
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="translation column is required"):
|
||||
service.create_job(payload)
|
||||
await service.create_job(payload)
|
||||
# #endregion test_create_job_missing_translation_column
|
||||
|
||||
|
||||
# #region test_create_job_invalid_upsert_strategy [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that an invalid upsert strategy is rejected.
|
||||
def test_create_job_invalid_upsert_strategy(db_session):
|
||||
async def test_create_job_invalid_upsert_strategy(db_session):
|
||||
"""Test that an invalid upsert strategy raises ValueError."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -247,13 +247,13 @@ def test_create_job_invalid_upsert_strategy(db_session):
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid upsert_strategy"):
|
||||
service.create_job(payload)
|
||||
await service.create_job(payload)
|
||||
# #endregion test_create_job_invalid_upsert_strategy
|
||||
|
||||
|
||||
# #region test_get_job [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a job can be retrieved by ID.
|
||||
def test_get_job(db_session):
|
||||
async def test_get_job(db_session):
|
||||
"""Test retrieving a translation job by ID."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -262,7 +262,7 @@ def test_get_job(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
created = service.create_job(payload)
|
||||
created = await service.create_job(payload)
|
||||
|
||||
fetched = service.get_job(created.id)
|
||||
assert fetched.id == created.id
|
||||
@@ -286,7 +286,7 @@ def test_get_job_not_found(db_session):
|
||||
|
||||
# #region test_list_jobs [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that listing jobs returns all created jobs.
|
||||
def test_list_jobs(db_session):
|
||||
async def test_list_jobs(db_session):
|
||||
"""Test listing translation jobs."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -296,8 +296,8 @@ def test_list_jobs(db_session):
|
||||
|
||||
p1 = TranslateJobCreate(name="Job 1", source_dialect="postgresql", target_dialect="clickhouse")
|
||||
p2 = TranslateJobCreate(name="Job 2", source_dialect="mysql", target_dialect="postgresql")
|
||||
service.create_job(p1)
|
||||
service.create_job(p2)
|
||||
await service.create_job(p1)
|
||||
await service.create_job(p2)
|
||||
|
||||
total, jobs = service.list_jobs()
|
||||
assert total == 2
|
||||
@@ -307,7 +307,7 @@ def test_list_jobs(db_session):
|
||||
|
||||
# #region test_list_jobs_with_status_filter [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that listing jobs with a status filter works.
|
||||
def test_list_jobs_with_status_filter(db_session):
|
||||
async def test_list_jobs_with_status_filter(db_session):
|
||||
"""Test listing jobs filtered by status."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
||||
@@ -317,10 +317,10 @@ def test_list_jobs_with_status_filter(db_session):
|
||||
|
||||
p1 = TranslateJobCreate(name="Draft Job", source_dialect="pg", target_dialect="ch")
|
||||
p2 = TranslateJobCreate(name="Ready Job", source_dialect="pg", target_dialect="ch")
|
||||
service.create_job(p1)
|
||||
job2 = service.create_job(p2)
|
||||
await service.create_job(p1)
|
||||
job2 = await service.create_job(p2)
|
||||
|
||||
service.update_job(job2.id, TranslateJobUpdate(status="READY"))
|
||||
await service.update_job(job2.id, TranslateJobUpdate(status="READY"))
|
||||
|
||||
total, jobs = service.list_jobs(status_filter="READY")
|
||||
assert total == 1
|
||||
@@ -330,7 +330,7 @@ def test_list_jobs_with_status_filter(db_session):
|
||||
|
||||
# #region test_update_job [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a job can be updated.
|
||||
def test_update_job(db_session):
|
||||
async def test_update_job(db_session):
|
||||
"""Test updating a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate, TranslateJobUpdate
|
||||
@@ -339,14 +339,14 @@ def test_update_job(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
update = TranslateJobUpdate(
|
||||
name="Updated Job",
|
||||
description="Updated description",
|
||||
batch_size=200,
|
||||
)
|
||||
updated = service.update_job(job.id, update)
|
||||
updated = await service.update_job(job.id, update)
|
||||
|
||||
assert updated.name == "Updated Job"
|
||||
assert updated.description == "Updated description"
|
||||
@@ -356,7 +356,7 @@ def test_update_job(db_session):
|
||||
|
||||
# #region test_delete_job [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a job can be deleted.
|
||||
def test_delete_job(db_session):
|
||||
async def test_delete_job(db_session):
|
||||
"""Test deleting a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -365,7 +365,7 @@ def test_delete_job(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
job = service.create_job(payload)
|
||||
job = await service.create_job(payload)
|
||||
|
||||
service.delete_job(job.id)
|
||||
|
||||
@@ -376,7 +376,7 @@ def test_delete_job(db_session):
|
||||
|
||||
# #region test_duplicate_job [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a job can be duplicated.
|
||||
def test_duplicate_job(db_session):
|
||||
async def test_duplicate_job(db_session):
|
||||
"""Test duplicating a translation job."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -385,7 +385,7 @@ def test_duplicate_job(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
original = service.create_job(payload)
|
||||
original = await service.create_job(payload)
|
||||
|
||||
duplicate = service.duplicate_job(original.id)
|
||||
|
||||
@@ -401,7 +401,7 @@ def test_duplicate_job(db_session):
|
||||
|
||||
# #region test_duplicate_job_custom_name [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify that a job can be duplicated with a custom name.
|
||||
def test_duplicate_job_custom_name(db_session):
|
||||
async def test_duplicate_job_custom_name(db_session):
|
||||
"""Test duplicating a job with a custom name."""
|
||||
from src.plugins.translate.service import TranslateJobService
|
||||
from src.schemas.translate import TranslateJobCreate
|
||||
@@ -410,7 +410,7 @@ def test_duplicate_job_custom_name(db_session):
|
||||
service = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
|
||||
payload = TranslateJobCreate(**valid_job_payload)
|
||||
original = service.create_job(payload)
|
||||
original = await service.create_job(payload)
|
||||
|
||||
duplicate = service.duplicate_job(original.id, new_name="Custom Copy Name")
|
||||
assert duplicate.name == "Custom Copy Name"
|
||||
|
||||
@@ -57,14 +57,14 @@ def db_session():
|
||||
|
||||
# #region test_create_schedule [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify schedule creation with valid params.
|
||||
def test_create_schedule(db_session):
|
||||
async def test_create_schedule(db_session):
|
||||
"""Test creating a schedule for a job."""
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
payload = TranslateJobCreate(name="Sched Job", source_dialect="pg", target_dialect="ch")
|
||||
job = svc.create_job(payload)
|
||||
job = await svc.create_job(payload)
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
schedule = scheduler.create_schedule(
|
||||
@@ -83,13 +83,13 @@ def test_create_schedule(db_session):
|
||||
|
||||
# #region test_update_schedule [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify schedule update.
|
||||
def test_update_schedule(db_session):
|
||||
async def test_update_schedule(db_session):
|
||||
"""Test updating a schedule."""
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
job = await svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
@@ -103,13 +103,13 @@ def test_update_schedule(db_session):
|
||||
|
||||
# #region test_delete_schedule [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify schedule deletion.
|
||||
def test_delete_schedule(db_session):
|
||||
async def test_delete_schedule(db_session):
|
||||
"""Test deleting a schedule."""
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
job = await svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
@@ -122,13 +122,13 @@ def test_delete_schedule(db_session):
|
||||
|
||||
# #region test_enable_disable_schedule [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify enable/disable toggle.
|
||||
def test_enable_disable_schedule(db_session):
|
||||
async def test_enable_disable_schedule(db_session):
|
||||
"""Test enabling and disabling a schedule."""
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job = svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
job = await svc.create_job(TranslateJobCreate(name="Job", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job.id, "0 2 * * *")
|
||||
@@ -156,13 +156,13 @@ def test_get_schedule_not_found(db_session):
|
||||
|
||||
# #region test_list_active_schedules [C:2] [TYPE Function]
|
||||
# @PURPOSE: Verify listing only active schedules.
|
||||
def test_list_active_schedules(db_session):
|
||||
async def test_list_active_schedules(db_session):
|
||||
"""Test listing active schedules."""
|
||||
config_mgr = MagicMock()
|
||||
config_mgr.get_environments.return_value = []
|
||||
|
||||
svc = TranslateJobService(db_session, config_mgr, "test_user")
|
||||
job1 = svc.create_job(TranslateJobCreate(name="Job1", source_dialect="pg", target_dialect="ch"))
|
||||
job1 = await svc.create_job(TranslateJobCreate(name="Job1", source_dialect="pg", target_dialect="ch"))
|
||||
|
||||
scheduler = TranslationScheduler(db_session, config_mgr, "test_user")
|
||||
scheduler.create_schedule(job1.id, "0 2 * * *")
|
||||
|
||||
Reference in New Issue
Block a user