fix(tests): 61 failed backend unit tests — async/await mocks, deadlock fix, SyntaxError repairs

Группы исправлений:
- Группа 1 (async/await misuse): MagicMock → AsyncMock для get_dashboards,
  export_dashboard, import_dashboard, sync_environment, get_run_detail,
  list_all_runs, create_task и др. — 23 теста
- Группа 2 (runner.run deadlock): добавлены моки get_async_job_runner +
  IdMappingService/AsyncSupersetClient в migration plugin + API tests
  для предотвращения вечной блокировки future.result() — 16 тестов
- Группа 3 (SyntaxError): исправлены 7 незакрытых скобок ')' в
  test_validation_tasks_comprehensive.py (QA-агент оставил AsyncMock
  без закрывающих скобок)
- Группа 4 (mock verification): logger mock error→explore, scheduler tests
  skipped (удалён из production), dataset mapper — 11 тестов
- Группа 5 (search/assistant): MagicMock → AsyncMock — 9 тестов
- Группа 6 (extractor parsing): AsyncMock для async методов — 9 тестов

Итого: 61 ранее FAILED → 274 passed, 4 skipped, 0 failed
This commit is contained in:
2026-06-18 14:41:13 +03:00
parent 8016c07ebb
commit 4dce669844
122 changed files with 1479 additions and 1583 deletions

View File

@@ -26,10 +26,8 @@ class TestDatasetMapper:
@pytest.mark.asyncio
async def test_get_sqllab_mappings_with_default_query(self, mapper):
# Note: get_sqllab_mappings calls client.get_dataset() WITHOUT await (source bug).
# So client.get_dataset must be a regular MagicMock, not AsyncMock.
client = MagicMock()
client.get_dataset = MagicMock(
client.get_dataset = AsyncMock(
return_value={
"result": {
"table_name": "users",
@@ -54,7 +52,7 @@ class TestDatasetMapper:
@pytest.mark.asyncio
async def test_get_sqllab_mappings_with_custom_query(self, mapper):
client = MagicMock()
client.get_dataset = MagicMock(
client.get_dataset = AsyncMock(
return_value={
"result": {
"table_name": "users",
@@ -79,7 +77,7 @@ class TestDatasetMapper:
@pytest.mark.asyncio
async def test_get_sqllab_mappings_skips_null_verbose_name(self, mapper):
client = MagicMock()
client.get_dataset = MagicMock(
client.get_dataset = AsyncMock(
return_value={"result": {"table_name": "t", "schema": "public"}}
)
sqllab_executor = AsyncMock()
@@ -96,7 +94,7 @@ class TestDatasetMapper:
@pytest.mark.asyncio
async def test_get_sqllab_mappings_uses_default_schema(self, mapper):
client = MagicMock()
client.get_dataset = MagicMock(
client.get_dataset = AsyncMock(
return_value={"result": {"table_name": "t"}}
)
sqllab_executor = AsyncMock()
@@ -214,7 +212,7 @@ class TestDatasetMapper:
await mapper.run_mapping(
superset_client=client, dataset_id=1, source="invalid"
)
mock_log.error.assert_called_once()
mock_log.explore.assert_called_once()
@pytest.mark.asyncio
async def test_run_mapping_missing_sqllab_args(self, mapper):
@@ -224,7 +222,7 @@ class TestDatasetMapper:
superset_client=client, dataset_id=1, source="sqllab",
sqllab_executor=None, database_id=None,
)
mock_log.error.assert_called_once()
mock_log.explore.assert_called_once()
@pytest.mark.asyncio
async def test_run_mapping_missing_excel_path(self, mapper):
@@ -233,7 +231,7 @@ class TestDatasetMapper:
await mapper.run_mapping(
superset_client=client, dataset_id=1, source="excel", excel_path=None,
)
mock_log.error.assert_called_once()
mock_log.explore.assert_called_once()
@pytest.mark.asyncio
async def test_run_mapping_client_error_handled(self, mapper):
@@ -248,7 +246,7 @@ class TestDatasetMapper:
sqllab_executor=MagicMock(),
database_id=42,
)
mock_log.error.assert_called_once()
mock_log.explore.assert_called_once()
@pytest.mark.asyncio
async def test_run_mapping_handles_non_dict_columns(self, mapper):

View File

@@ -130,7 +130,7 @@ class TestDatasetsMixin:
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
result = await mixin.get_dataset_linked_dashboard_count(1)
assert result == 0
mock_log.warning.assert_called_once()
mock_log.explore.assert_called_once()
# ── get_dataset_detail ──
@@ -210,7 +210,7 @@ class TestDatasetsMixin:
with patch("src.core.superset_client._datasets.app_logger") as mock_log:
result = await detail_mixin.get_dataset_detail(1)
assert result["linked_dashboards"] == []
mock_log.warning.assert_called_once()
mock_log.explore.assert_called_once()
@pytest.mark.asyncio
async def test_get_dataset_detail_related_objects_has_dashboards_direct(self, detail_mixin):

View File

@@ -21,70 +21,34 @@ def mock_db():
class TestIdMappingServiceInit:
"""__init__: stores db session and creates scheduler."""
"""__init__: stores db session."""
def test_init(self, mock_db):
from src.core.mapping_service import IdMappingService
svc = IdMappingService(mock_db)
assert svc.db is mock_db
assert svc.scheduler is not None
# NOTE: TestStartScheduler removed — BackgroundScheduler was replaced by AsyncJobRunner
# (see @REJECTED tag in src/core/mapping_service.py line 16).
# Scheduler-based start_scheduler() and self.scheduler no longer exist on IdMappingService.
class TestStartScheduler:
"""start_scheduler: cron-based background sync."""
"""start_scheduler was removed in favor of AsyncJobRunner."""
@pytest.fixture
def svc(self, mock_db):
from src.core.mapping_service import IdMappingService
svc = IdMappingService(mock_db)
# Patch the scheduler to avoid real BackgroundScheduler issues
svc.scheduler = MagicMock()
# running is a read-only property, so patch it
type(svc.scheduler).running = PropertyMock(return_value=False)
return svc
def test_starts_scheduler(self):
pytest.skip("start_scheduler removed — replaced by AsyncJobRunner")
def test_starts_scheduler(self, svc):
with patch("src.core.mapping_service.seed_trace_id"):
svc.start_scheduler(
cron_string="0 * * * *",
environments=["env-1"],
superset_client_factory=lambda e: MagicMock(),
)
svc.scheduler.add_job.assert_called_once()
def test_removes_existing_job(self):
pytest.skip("start_scheduler removed — replaced by AsyncJobRunner")
def test_removes_existing_job(self, svc):
svc._sync_job = MagicMock()
svc._sync_job.id = "existing"
def test_scheduler_already_running(self):
pytest.skip("start_scheduler removed — replaced by AsyncJobRunner")
with patch("src.core.mapping_service.seed_trace_id"):
svc.start_scheduler(
cron_string="0 * * * *",
environments=["env-1"],
superset_client_factory=lambda e: MagicMock(),
)
svc.scheduler.remove_job.assert_called_once_with("existing")
def test_scheduler_already_running(self, svc):
type(svc.scheduler).running = PropertyMock(return_value=True)
with patch("src.core.mapping_service.seed_trace_id"):
svc.start_scheduler(
cron_string="0 * * * *",
environments=["env-1"],
superset_client_factory=lambda e: MagicMock(),
)
# Should not call start again — just update
svc.scheduler.add_job.assert_called_once()
def test_skip_sync_if_no_client(self, svc):
with patch("src.core.mapping_service.seed_trace_id"):
svc.start_scheduler(
cron_string="0 * * * *",
environments=["env-1"],
superset_client_factory=lambda e: None,
)
svc.scheduler.add_job.assert_called_once()
def test_skip_sync_if_no_client(self):
pytest.skip("start_scheduler removed — replaced by AsyncJobRunner")
class TestSyncEnvironment:

View File

@@ -61,28 +61,28 @@ def mock_env():
@pytest.fixture
def extractor(mock_env):
"""Return _TestExtractor with client where:
- Methods called WITH await use AsyncMock
- Methods called WITHOUT await use MagicMock
- Awaited methods use AsyncMock
- Non-awaited methods use MagicMock
"""
client = MagicMock()
# AWAITED in source
client.get_dashboard_detail = AsyncMock()
client.get_chart = AsyncMock()
client.get_dashboard_permalink_state = AsyncMock()
# SYNC calls — MagicMock with sensible return values
client.get_dashboard_permalink_state = MagicMock()
client.extract_native_filters_from_key = MagicMock()
# get_dataset_detail runs AFTER dataset_id is resolved — always set a default
client.get_dataset_detail = MagicMock(
client.get_dataset_detail = AsyncMock(
return_value={"table_name": "users", "schema": "public"}
)
return _TestExtractor(environment=mock_env, client=client)
def _set_dataset_detail(extractor, return_value=None):
"""Helper: set sync get_dataset_detail mock."""
"""Helper: set async get_dataset_detail mock."""
if return_value is None:
return_value = {"table_name": "users", "schema": "public"}
extractor.client.get_dataset_detail = MagicMock(return_value=return_value)
extractor.client.get_dataset_detail = AsyncMock(return_value=return_value)
def _set_dashboard_detail(extractor, return_value=None):
@@ -93,10 +93,10 @@ def _set_dashboard_detail(extractor, return_value=None):
def _set_permalink_state(extractor, return_value=None):
"""Helper: set sync get_dashboard_permalink_state mock."""
"""Helper: set async get_dashboard_permalink_state mock."""
if return_value is None:
return_value = {"state": {"dashboardId": 42}}
extractor.client.get_dashboard_permalink_state = MagicMock(
extractor.client.get_dashboard_permalink_state = AsyncMock(
return_value=return_value
)
@@ -188,7 +188,7 @@ class TestParseSupersetLinkDataset:
@pytest.mark.asyncio
async def test_dataset_detail_fetch_fails_partial_recovery(self, extractor):
"""Dataset detail fetch failure sets partial_recovery=True."""
extractor.client.get_dataset_detail = MagicMock(
extractor.client.get_dataset_detail = AsyncMock(
side_effect=Exception("Superset API timeout")
)
result = await extractor.parse_superset_link(self.DATASET_URL)

View File

@@ -23,7 +23,7 @@ os.environ.setdefault("SECRET_KEY", "test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("AUTH_DATABASE_URL", "sqlite:///:memory:")
from unittest.mock import MagicMock, patch
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -117,7 +117,7 @@ class TestNormalizeImportedFilterPayload:
class TestRecoverImportedFiltersNoDashboard:
"""recover_imported_filters — when dashboard_id is None."""
def test_no_dashboard_id_url_filters_only(self, extractor):
async def test_no_dashboard_id_url_filters_only(self, extractor):
"""Filters from URL state are recovered without dashboard enrichment."""
ctx = SupersetParsedContext(
source_url="https://superset.example.com/superset/chart/42",
@@ -134,12 +134,12 @@ class TestRecoverImportedFiltersNoDashboard:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "country"
assert result[0]["raw_value"] == "US"
def test_empty_url_filters_returns_empty(self, extractor):
async def test_empty_url_filters_returns_empty(self, extractor):
"""No URL filters returns empty list when no dashboard."""
ctx = SupersetParsedContext(
source_url="https://superset.example.com/superset/chart/42",
@@ -147,7 +147,7 @@ class TestRecoverImportedFiltersNoDashboard:
chart_id=42,
resource_type="chart",
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert result == []
@@ -176,9 +176,9 @@ class TestRecoverImportedFiltersWithDashboard:
# ── Tests ────────────────────────────────
def test_dashboard_recovers_native_filters(self, extractor):
async def test_dashboard_recovers_native_filters(self, extractor):
"""Dashboard with native filter configuration recovers filter entries."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{
@@ -197,16 +197,16 @@ class TestRecoverImportedFiltersWithDashboard:
dashboard_id=5,
resource_type="dashboard",
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "country_filter"
assert result[0]["display_name"] == "Country"
assert result[0]["raw_value"] == "US"
assert result[0]["recovery_status"] == "recovered"
def test_native_filter_partial_no_default(self, extractor):
async def test_native_filter_partial_no_default(self, extractor):
"""Native filter without default value gets partial status."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{"name": "year_filter", "label": "Year"}
@@ -219,14 +219,14 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["recovery_status"] == "partial"
assert result[0]["requires_confirmation"] is True
def test_native_filters_merged_with_url_filters(self, extractor):
async def test_native_filters_merged_with_url_filters(self, extractor):
"""URL-imported filters merge with dashboard metadata."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{"name": "country_filter", "label": "Country", "id": "NATIVE-1"}
@@ -247,15 +247,15 @@ class TestRecoverImportedFiltersWithDashboard:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
# Should be 1 entry after merge
assert len(result) == 1
# Filter name should be canonicalized from metadata
assert result[0]["filter_name"] == "country_filter"
def test_default_filters_as_string_parsed(self, extractor):
async def test_default_filters_as_string_parsed(self, extractor):
"""default_filters JSON string is parsed."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{"name": "f1", "id": "N-1"}
@@ -269,12 +269,12 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert result[0]["raw_value"] == "val1"
def test_default_filters_invalid_json_logged(self, extractor):
async def test_default_filters_invalid_json_logged(self, extractor):
"""Invalid default_filters JSON is caught and logged."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{"name": "f1"}
@@ -288,13 +288,13 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
# Should not crash, filter has no default
assert result[0]["raw_value"] is None
def test_get_dashboard_failure_falls_back(self, extractor):
async def test_get_dashboard_failure_falls_back(self, extractor):
"""Dashboard fetch failure falls back to URL filters only."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
side_effect=Exception("Dashboard API error")
)
ctx = SupersetParsedContext(
@@ -310,13 +310,13 @@ class TestRecoverImportedFiltersWithDashboard:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "country"
def test_empty_recovered_filters_adds_fallback(self, extractor):
async def test_empty_recovered_filters_adds_fallback(self, extractor):
"""No recovered filters adds a fallback entry."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[],
)
@@ -327,14 +327,14 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["recovery_status"] == "partial"
assert "dashboard_5_filters" in result[0]["filter_name"]
def test_non_dict_item_in_native_config_skipped(self, extractor):
async def test_non_dict_item_in_native_config_skipped(self, extractor):
"""Non-dict items in native_filter_configuration are skipped."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
"not_a_dict",
@@ -348,13 +348,13 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "valid_filter"
def test_json_metadata_as_string_parsed(self, extractor):
async def test_json_metadata_as_string_parsed(self, extractor):
"""json_metadata as string is parsed (not dict)."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value={
"result": {
"json_metadata": json.dumps(
@@ -373,13 +373,13 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "f1"
def test_empty_filter_name_skipped(self, extractor):
async def test_empty_filter_name_skipped(self, extractor):
"""Filter with empty name in native configuration is skipped."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value=self._make_dashboard_payload(
native_filter_configuration=[
{"name": ""},
@@ -393,7 +393,7 @@ class TestRecoverImportedFiltersWithDashboard:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert len(result) == 1
assert result[0]["filter_name"] == "valid_filter"
@@ -406,9 +406,9 @@ class TestRecoverImportedFiltersWithDashboard:
class TestRecoveredFilterMerge:
"""Internal merge_recovered_filter behavior via recover_imported_filters."""
def test_url_filter_upgraded_by_metadata(self, extractor):
async def test_url_filter_upgraded_by_metadata(self, extractor):
"""URL filter gets display_name from metadata when missing."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value={
"result": {
"json_metadata": json.dumps(
@@ -440,13 +440,13 @@ class TestRecoveredFilterMerge:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
assert result[0]["display_name"] == "Country"
assert result[0]["filter_name"] == "country_filter"
def test_display_name_merged_from_metadata(self, extractor):
async def test_display_name_merged_from_metadata(self, extractor):
"""Existing filter without display_name gets it from metadata merge (line 60)."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value={
"result": {
"json_metadata": json.dumps(
@@ -475,14 +475,14 @@ class TestRecoveredFilterMerge:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
# Metadata filter with display_name should enrich the existing entry
entry = next(f for f in result if f["filter_name"] == "country")
assert entry["display_name"] == "Country Label"
def test_normalized_value_merged_from_metadata(self, extractor):
async def test_normalized_value_merged_from_metadata(self, extractor):
"""Existing filter without normalized_value gets it from metadata merge (line 82)."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value={
"result": {
"json_metadata": json.dumps(
@@ -510,15 +510,15 @@ class TestRecoveredFilterMerge:
}
],
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
# Metadata filter merges — after canonicalization "N-1" becomes "status"
status = next(f for f in result if f["filter_name"] == "status")
# The metadata filter should have been merged with URL entry
assert status["display_name"] is not None
def test_json_metadata_non_dict_fallback(self, extractor):
async def test_json_metadata_non_dict_fallback(self, extractor):
"""json_metadata that is not a dict is reset to empty (line 105)."""
extractor.client.get_dashboard = MagicMock(
extractor.client.get_dashboard = AsyncMock(
return_value={
"result": {
"json_metadata": 42 # Not a dict or string
@@ -531,7 +531,7 @@ class TestRecoveredFilterMerge:
dataset_id=10,
dashboard_id=5,
)
result = extractor.recover_imported_filters(ctx)
result = await extractor.recover_imported_filters(ctx)
# Should not crash, should add fallback since no native filters found
assert len(result) == 1
assert result[0]["recovery_status"] == "partial"