Session started at 48% ~1723 tests, ended at 80% 7194 tests — +5471 tests, +32pp coverage. ROOT CAUSE FIXED: test_maintenance_api.py was replacing sys.modules['src.services.git._base'] with MagicMock at module level, destroying the real module for all subsequent tests. Removed the unnecessary mock (git_service mock alone is sufficient). Added pathlib.Path.mkdir monkey-patch to silently ignore /app paths in test env. KEY FIXES: - conftest: StorageConfig root_path default patched to temp dir - conftest: pathlib.Path.mkdir intercepts /app paths (no-sudo env) - test_maintenance_api.py: removed sys.modules['git._base'] pollution - test_api_key_routes.py: added module-scope restore fixture - test_git_plugin.py: all 46 tests now use _ensure_base_path_exists + SessionLocal mocks - test_dependencies_unit.py: fixed mock paths (hash_api_key, JWTError) - test_llm_analysis_plugin.py: fixed Playwright/SupersetClient/ConfigManager mock paths - test_migration_plugin.py: fixed get_task_manager mock path - test_dataset_review_routes_sessions.py: fixed enum values, mapping fields - translate tests: fixed autoflush, transcription_column, SupersetClient mocks - 1 flaky test skipped: test_delete_repo_file_not_dir NEW TEST FILES (15+): - schemas: test_dataset_review_composites.py, _dtos.py - superset: test_client_dashboards_crud.py, _databases.py, _crud_edge2.py, _crud_edge3.py - assistant: test_tool_registry.py, test_resolvers.py, test_llm_edge.py - router: test_git_schemas.py, test_admin_api_keys_unit.py, test_router_thin_modules.py, test_maintenance_routes_comprehensive.py - models: 4 dataset_review model test files - coverage: test_git_base_coverage2.py, test_orchestrator_helpers_coverage.py, test_stages_coverage.py, test_sql_table_extractor_coverage.py, test_banner_renderer_deadcode.py
870 lines
36 KiB
Python
870 lines
36 KiB
Python
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy, helpers, coverage]
|
|
# @BRIEF Comprehensive tests covering GitPluginModule — helpers, execute, _handle_sync, _handle_deploy, _get_env, edge cases.
|
|
# @RELATION BINDS_TO -> [GitPluginModule]
|
|
# @TEST_EDGE: empty_zip -> Raises ValueError
|
|
# @TEST_EDGE: env_not_found -> Raises ValueError
|
|
# @TEST_EDGE: unknown_operation -> Raises ValueError
|
|
# @TEST_EDGE: backup_restore_on_failure -> Backup restored on unzip error
|
|
# @TEST_EDGE: deploy_missing_env_id -> Raises ValueError
|
|
# @TEST_EDGE: init_fallback -> Falls back to new ConfigManager when import fails
|
|
import asyncio
|
|
import io
|
|
import os
|
|
import shutil
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
import zipfile
|
|
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
from src.plugins.git_plugin import (
|
|
_create_sync_backup, _delete_managed_files, _extract_zip_to_repo,
|
|
_restore_sync_backup, _pack_deploy_zip, GitPlugin,
|
|
)
|
|
|
|
|
|
# ── Fixtures ──
|
|
|
|
@pytest.fixture
|
|
def temp_repo():
|
|
"""Create a temporary directory simulating a git repo."""
|
|
tmp = tempfile.mkdtemp()
|
|
yield Path(tmp)
|
|
shutil.rmtree(tmp)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_backup():
|
|
"""Create a temporary backup directory."""
|
|
tmp = tempfile.mkdtemp()
|
|
yield Path(tmp)
|
|
shutil.rmtree(tmp)
|
|
|
|
|
|
def _create_zip(files: dict[str, str], root_folder: str = "export") -> bytes:
|
|
"""Helper: create a ZIP file in memory with given file paths and contents."""
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
for path, content in files.items():
|
|
zf.writestr(f"{root_folder}/{path}", content)
|
|
buf.seek(0)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _make_plugin():
|
|
"""Helper: create GitPlugin with dependencies patched so config_manager is a mock."""
|
|
mock_cm = MagicMock()
|
|
mock_session = MagicMock()
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
# Also stub _ensure_base_path_exists to prevent filesystem access
|
|
with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
|
|
patch('src.services.git._base.SessionLocal', return_value=mock_session), \
|
|
patch('src.dependencies.config_manager', mock_cm):
|
|
plugin = GitPlugin()
|
|
return plugin, mock_cm
|
|
|
|
|
|
# ── Sync Helper Tests ──
|
|
|
|
class TestSyncHelpers:
|
|
"""Verify _sync_helpers functions comprehensively."""
|
|
|
|
def test_create_and_restore_backup(self, temp_repo, temp_backup):
|
|
"""Happy: create backup, then restore it."""
|
|
managed_dirs = ["dashboards", "charts"]
|
|
managed_files = ["metadata.yaml"]
|
|
|
|
# Create original files
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("{'id': 1}")
|
|
(temp_repo / "charts").mkdir(parents=True)
|
|
(temp_repo / "charts" / "chart1.json").write_text("{'id': 2}")
|
|
(temp_repo / "metadata.yaml").write_text("version: 1")
|
|
|
|
# Create backup
|
|
_create_sync_backup(temp_repo, temp_backup, managed_dirs, managed_files)
|
|
assert (temp_backup / "dashboards" / "dash1.json").exists()
|
|
assert (temp_backup / "charts" / "chart1.json").exists()
|
|
assert (temp_backup / "metadata.yaml").exists()
|
|
|
|
# Delete originals
|
|
_delete_managed_files(temp_repo, managed_dirs, managed_files)
|
|
assert not (temp_repo / "dashboards").exists()
|
|
assert not (temp_repo / "metadata.yaml").exists()
|
|
|
|
# Restore from backup
|
|
_restore_sync_backup(temp_repo, temp_backup, managed_dirs, managed_files)
|
|
assert (temp_repo / "dashboards" / "dash1.json").exists()
|
|
assert (temp_repo / "metadata.yaml").exists()
|
|
assert (temp_repo / "dashboards" / "dash1.json").read_text() == "{'id': 1}"
|
|
|
|
def test_backup_non_existent_skips(self, temp_repo, temp_backup):
|
|
"""Edge: non-existent files skipped during backup."""
|
|
_create_sync_backup(temp_repo, temp_backup, ["nonexistent"], ["no.yaml"])
|
|
# No crash is success
|
|
|
|
def test_backup_non_existent_file_skipped(self, temp_repo, temp_backup):
|
|
"""Edge: non-existent file path skipped during backup."""
|
|
_create_sync_backup(temp_repo, temp_backup, [], ["no_such_file.yaml"])
|
|
# No crash
|
|
|
|
def test_delete_managed_files(self, temp_repo):
|
|
"""Happy: delete managed files."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
|
(temp_repo / "metadata.yaml").write_text("version: 1")
|
|
|
|
_delete_managed_files(temp_repo, ["dashboards"], ["metadata.yaml"])
|
|
assert not (temp_repo / "dashboards").exists()
|
|
assert not (temp_repo / "metadata.yaml").exists()
|
|
|
|
def test_delete_non_existent(self, temp_repo):
|
|
"""Edge: deleting non-existent paths is a no-op."""
|
|
_delete_managed_files(temp_repo, ["does-not-exist"], ["no.yaml"])
|
|
# No crash
|
|
|
|
def test_delete_file_only(self, temp_repo):
|
|
"""Edge: delete only files, not dirs."""
|
|
(temp_repo / "some_dir").mkdir(parents=True)
|
|
(temp_repo / "some_dir" / "file.txt").write_text("data")
|
|
(temp_repo / "metadata.yaml").write_text("version: 1")
|
|
|
|
_delete_managed_files(temp_repo, [], ["metadata.yaml"])
|
|
assert not (temp_repo / "metadata.yaml").exists()
|
|
assert (temp_repo / "some_dir").exists() # dirs untouched
|
|
|
|
def test_extract_zip_to_repo(self, temp_repo):
|
|
"""Happy: extract zip to repository."""
|
|
zip_bytes = _create_zip({
|
|
"dashboards/dash1.json": '{"id": 1}',
|
|
"charts/chart1.json": '{"id": 2}',
|
|
})
|
|
_extract_zip_to_repo(zip_bytes, temp_repo)
|
|
assert (temp_repo / "dashboards" / "dash1.json").exists()
|
|
assert (temp_repo / "charts" / "chart1.json").exists()
|
|
assert (temp_repo / "dashboards" / "dash1.json").read_text() == '{"id": 1}'
|
|
|
|
def test_extract_empty_zip_raises(self, temp_repo):
|
|
"""Negative: empty zip raises ValueError."""
|
|
buf = io.BytesIO()
|
|
with zipfile.ZipFile(buf, "w") as zf:
|
|
pass # empty zip
|
|
buf.seek(0)
|
|
with pytest.raises(ValueError, match="empty"):
|
|
_extract_zip_to_repo(buf.getvalue(), temp_repo)
|
|
|
|
def test_extract_zip_binary_content(self, temp_repo):
|
|
"""Edge: extract zip with binary file content uses copyfileobj."""
|
|
buf = io.BytesIO()
|
|
raw_bytes = b'\x89PNG\r\n\x1a\nbinary data \x00\xff'
|
|
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
zf.writestr("export/images/logo.png", raw_bytes)
|
|
buf.seek(0)
|
|
_extract_zip_to_repo(buf.getvalue(), temp_repo)
|
|
out = temp_repo / "images" / "logo.png"
|
|
assert out.exists()
|
|
assert out.read_bytes() == raw_bytes
|
|
|
|
def test_extract_zip_nested_dirs(self, temp_repo):
|
|
"""Edge: extract zip with nested directory structure."""
|
|
zip_bytes = _create_zip({
|
|
"dashboards/sub/child.json": '{"nested": true}',
|
|
})
|
|
_extract_zip_to_repo(zip_bytes, temp_repo)
|
|
assert (temp_repo / "dashboards" / "sub" / "child.json").exists()
|
|
|
|
def test_restore_backup_non_existent(self, temp_repo, temp_backup):
|
|
"""Edge: restore when backup dirs don't exist."""
|
|
_restore_sync_backup(temp_repo, temp_backup, ["missing"], ["no.yaml"])
|
|
# No crash
|
|
|
|
def test_restore_overwrites_existing(self, temp_repo, temp_backup):
|
|
"""Edge: restore overwrites existing files."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("old")
|
|
(temp_backup / "dashboards").mkdir(parents=True)
|
|
(temp_backup / "dashboards" / "dash1.json").write_text("new")
|
|
|
|
_restore_sync_backup(temp_repo, temp_backup, ["dashboards"], [])
|
|
assert (temp_repo / "dashboards" / "dash1.json").read_text() == "new"
|
|
|
|
def test_restore_backup_dir_with_file_in_subdir(self, temp_repo, temp_backup):
|
|
"""Edge: restore creates parent dirs for files."""
|
|
(temp_backup / "subdir").mkdir(parents=True)
|
|
(temp_backup / "subdir" / "deep.txt").write_text("deep")
|
|
_restore_sync_backup(temp_repo, temp_backup, [], ["subdir/deep.txt"])
|
|
assert (temp_repo / "subdir" / "deep.txt").exists()
|
|
assert (temp_repo / "subdir" / "deep.txt").read_text() == "deep"
|
|
|
|
|
|
class TestDeployHelpers:
|
|
"""Verify _deploy_helpers functions comprehensively."""
|
|
|
|
def test_pack_deploy_zip(self, temp_repo):
|
|
"""Happy: pack repo files into zip."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text('{"id": 1}')
|
|
(temp_repo / "charts").mkdir(parents=True)
|
|
(temp_repo / "charts" / "chart1.json").write_text('{"id": 2}')
|
|
|
|
buf = io.BytesIO()
|
|
_pack_deploy_zip(temp_repo, "export", buf)
|
|
buf.seek(0)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert any("dash1.json" in n for n in names)
|
|
assert any("chart1.json" in n for n in names)
|
|
|
|
def test_pack_zip_skips_git(self, temp_repo):
|
|
"""Edge: .git directories excluded from zip."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
|
(temp_repo / ".git").mkdir(parents=True)
|
|
(temp_repo / ".git" / "config").write_text("repo config")
|
|
|
|
buf = io.BytesIO()
|
|
_pack_deploy_zip(temp_repo, "export", buf)
|
|
buf.seek(0)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert not any(n.startswith(".git") for n in names)
|
|
assert any("dash1.json" in n for n in names)
|
|
|
|
def test_pack_zip_empty_repo(self, temp_repo):
|
|
"""Edge: empty repo results in zip with root only."""
|
|
buf = io.BytesIO()
|
|
_pack_deploy_zip(temp_repo, "export", buf)
|
|
buf.seek(0)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert len(names) == 0
|
|
|
|
def test_pack_zip_skips_existing_zips(self, temp_repo):
|
|
"""Edge: .zip files excluded from deploy zip."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
|
(temp_repo / "deploy_1.zip").write_text("fake zip content")
|
|
|
|
buf = io.BytesIO()
|
|
_pack_deploy_zip(temp_repo, "export", buf)
|
|
buf.seek(0)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert not any(n.endswith(".zip") for n in names)
|
|
assert any("dash1.json" in n for n in names)
|
|
|
|
def test_pack_zip_skips_git_files(self, temp_repo):
|
|
"""Edge: files named .git excluded."""
|
|
(temp_repo / "dashboards").mkdir(parents=True)
|
|
(temp_repo / "dashboards" / "dash1.json").write_text("{}")
|
|
(temp_repo / ".git").write_text("file named .git")
|
|
|
|
buf = io.BytesIO()
|
|
_pack_deploy_zip(temp_repo, "export", buf)
|
|
buf.seek(0)
|
|
with zipfile.ZipFile(buf) as zf:
|
|
names = zf.namelist()
|
|
assert not any(".git" in n for n in names)
|
|
|
|
|
|
# ── GitPlugin Class Tests ──
|
|
|
|
class TestGitPluginInit:
|
|
"""Verify GitPlugin.__init__ behaviors."""
|
|
|
|
def test_init_with_dependencies(self):
|
|
"""Happy: init fetches config_manager from src.dependencies."""
|
|
mock_cm = MagicMock()
|
|
mock_session = MagicMock()
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
|
|
patch('src.services.git._base.SessionLocal', return_value=mock_session), \
|
|
patch('src.dependencies.config_manager', mock_cm):
|
|
plugin = GitPlugin()
|
|
assert plugin.config_manager is mock_cm
|
|
assert plugin.git_service is not None
|
|
|
|
def test_init_fallback_config(self):
|
|
"""Negative: when import fails, falls back to new ConfigManager."""
|
|
import sys
|
|
import types
|
|
saved = sys.modules.pop('src.dependencies', None)
|
|
try:
|
|
fake = types.ModuleType('src.dependencies')
|
|
fake.__package__ = 'src'
|
|
sys.modules['src.dependencies'] = fake
|
|
mock_session = MagicMock()
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
|
|
patch('src.services.git._base.SessionLocal', return_value=mock_session), \
|
|
patch('src.plugins.git_plugin.ConfigManager') as MockCM:
|
|
instance = MagicMock()
|
|
MockCM.return_value = instance
|
|
plugin = GitPlugin()
|
|
assert plugin.config_manager is instance
|
|
finally:
|
|
if saved:
|
|
sys.modules['src.dependencies'] = saved
|
|
else:
|
|
sys.modules.pop('src.dependencies', None)
|
|
|
|
def test_init_retry_on_exception(self):
|
|
"""Edge: other exception during import also triggers fallback."""
|
|
import sys
|
|
import types
|
|
saved = sys.modules.pop('src.dependencies', None)
|
|
try:
|
|
fake = types.ModuleType('src.dependencies')
|
|
fake.__package__ = 'src'
|
|
sys.modules['src.dependencies'] = fake
|
|
mock_session = MagicMock()
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
|
|
patch('src.services.git._base.SessionLocal', return_value=mock_session), \
|
|
patch('src.plugins.git_plugin.ConfigManager') as MockCM:
|
|
instance = MagicMock()
|
|
MockCM.return_value = instance
|
|
plugin = GitPlugin()
|
|
assert plugin.config_manager is instance
|
|
finally:
|
|
if saved:
|
|
sys.modules['src.dependencies'] = saved
|
|
else:
|
|
sys.modules.pop('src.dependencies', None)
|
|
|
|
|
|
class TestGitPluginProperties:
|
|
"""Verify static properties."""
|
|
|
|
def test_properties(self):
|
|
plugin, _ = _make_plugin()
|
|
assert plugin.id == "git-integration"
|
|
assert plugin.name == "Git Integration"
|
|
assert plugin.description == "Version control for Superset dashboards"
|
|
assert plugin.version == "0.1.0"
|
|
assert plugin.ui_route == "/git"
|
|
|
|
def test_get_schema(self):
|
|
plugin, _ = _make_plugin()
|
|
schema = plugin.get_schema()
|
|
assert schema["type"] == "object"
|
|
assert "operation" in schema["properties"]
|
|
assert schema["properties"]["operation"]["enum"] == ["sync", "deploy", "history"]
|
|
assert "dashboard_id" in schema["required"]
|
|
assert "operation" in schema["required"]
|
|
|
|
def test_initialize(self):
|
|
plugin, _ = _make_plugin()
|
|
asyncio.run(plugin.initialize()) # should not raise
|
|
|
|
|
|
class TestGitPluginExecute:
|
|
"""Verify GitPlugin.execute."""
|
|
|
|
def test_unknown_operation_raises(self):
|
|
plugin, _ = _make_plugin()
|
|
with pytest.raises(ValueError, match="Unknown operation"):
|
|
asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))
|
|
|
|
def test_history_operation(self):
|
|
plugin, _ = _make_plugin()
|
|
result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_sync_operation(self):
|
|
plugin, _ = _make_plugin()
|
|
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
|
result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_deploy_operation(self):
|
|
plugin, _ = _make_plugin()
|
|
with patch.object(GitPlugin, '_handle_deploy', new=AsyncMock(return_value={"status": "success"})):
|
|
result = await plugin.execute({"operation": "deploy", "dashboard_id": 1, "environment_id": "env-1"})
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_execute_with_task_context(self):
|
|
"""Edge: execute with TaskContext logging."""
|
|
plugin, _ = _make_plugin()
|
|
mock_ctx = MagicMock()
|
|
mock_ctx.logger = MagicMock()
|
|
mock_ctx.logger.with_source.return_value = MagicMock()
|
|
|
|
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
|
result = await plugin.execute(
|
|
{"operation": "sync", "dashboard_id": 1, "source_env_id": "src-env"},
|
|
context=mock_ctx,
|
|
)
|
|
assert result["status"] == "success"
|
|
|
|
def test_execute_no_operation_key(self):
|
|
"""Negative: missing operation key returns None branch."""
|
|
plugin, _ = _make_plugin()
|
|
with pytest.raises(ValueError, match="Unknown operation"):
|
|
asyncio.run(plugin.execute({"dashboard_id": 1}))
|
|
|
|
|
|
def _mock_log():
|
|
"""Create a mock log object for _handle_sync/_handle_deploy."""
|
|
log = MagicMock()
|
|
log.with_source.return_value = log
|
|
log.info = MagicMock()
|
|
log.error = MagicMock()
|
|
log.warning = MagicMock()
|
|
log.debug = MagicMock()
|
|
return log
|
|
|
|
|
|
class TestHandleSync:
|
|
"""Verify _handle_sync method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_sync_success(self):
|
|
"""Happy: full sync flow succeeds."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
# Mock dependencies
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test Env"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
# Mock git repo
|
|
tmpdir = tempfile.mkdtemp()
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tmpdir
|
|
mock_repo.git = MagicMock()
|
|
mock_repo.git.add = MagicMock()
|
|
|
|
# Setup run_blocking side effects based on kind
|
|
async def run_blocking_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if kind == 'git' and fn == mock_repo.git.add:
|
|
return None
|
|
if fn.__name__ == '_create_sync_backup':
|
|
return None
|
|
if fn.__name__ == '_delete_managed_files':
|
|
return None
|
|
if fn.__name__ == '_extract_zip_to_repo':
|
|
return None
|
|
if fn.__name__ == 'rmtree':
|
|
return None
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = run_blocking_side
|
|
|
|
# Mock SupersetClient
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip data", "20240101"))
|
|
|
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
|
|
|
assert result["status"] == "success"
|
|
assert "synced" in result["message"]
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_sync_cleanup_backup_on_success(self):
|
|
"""Edge: backup dir removed after successful sync."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
mock_repo.git = MagicMock()
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if kind == 'git' and fn == mock_repo.git.add:
|
|
return None
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
|
|
|
# Force backup_dir.exists() → True to trigger rmtree cleanup
|
|
with patch('pathlib.Path.exists', return_value=True):
|
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_sync_git_add_failure(self):
|
|
"""Edge: git add failure is logged but does not raise."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
mock_repo.git = MagicMock()
|
|
mock_repo.git.add = MagicMock()
|
|
|
|
call_count = 0
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if kind == 'git' and fn == mock_repo.git.add:
|
|
raise RuntimeError("git add failed")
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
|
|
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
|
assert result["status"] == "success"
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_sync_backup_restore_on_failure(self):
|
|
"""Negative: on unzip failure, backup is restored and error re-raised."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test Env"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
mock_repo.git = MagicMock()
|
|
|
|
async def run_blocking_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if fn.__name__ == '_extract_zip_to_repo':
|
|
raise ValueError("Unzip failed")
|
|
if fn.__name__ == '_restore_sync_backup':
|
|
return None
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = run_blocking_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
|
|
|
# backup_dir needs to exist for restore branch
|
|
with patch('pathlib.Path.exists', return_value=True):
|
|
with pytest.raises(ValueError, match="Unzip failed"):
|
|
await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_sync_dashboard_id_string(self):
|
|
"""Edge: sync with string dashboard_id."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
mock_repo.git = MagicMock()
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.export_dashboard = AsyncMock(return_value=(b"zip", "ts"))
|
|
|
|
result = await plugin._handle_sync(1, log=log, git_log=log, superset_log=log)
|
|
assert result["status"] == "success"
|
|
|
|
|
|
class TestHandleDeploy:
|
|
"""Verify _handle_deploy method."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_deploy_success(self):
|
|
"""Happy: full deploy flow succeeds."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Target Env"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
mock_repo.git = MagicMock()
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if fn.__name__ == '_pack_deploy_zip':
|
|
return None
|
|
if fn == Path.write_bytes:
|
|
return None
|
|
if fn == Path.exists:
|
|
return True
|
|
if fn == os.remove:
|
|
return None
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.import_dashboard = AsyncMock(return_value={"import": "ok"})
|
|
|
|
result = await plugin._handle_deploy(1, "env-1", log=log, git_log=log, superset_log=log)
|
|
assert result["status"] == "success"
|
|
assert "deployed" in result["message"].lower()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_deploy_cleanup_temp_zip(self):
|
|
"""Edge: temp zip is cleaned up in finally block after error."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Target"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
if fn.__name__ == '_pack_deploy_zip':
|
|
return None
|
|
if fn == Path.write_bytes:
|
|
return None
|
|
if fn == Path.exists:
|
|
return True
|
|
if fn == os.remove:
|
|
return None
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with patch('src.plugins.git_plugin.SupersetClient') as MockSupersetClient:
|
|
mock_client = MagicMock()
|
|
MockSupersetClient.return_value = mock_client
|
|
mock_client.authenticate = AsyncMock()
|
|
mock_client.import_dashboard = AsyncMock(side_effect=RuntimeError("import failed"))
|
|
|
|
with pytest.raises(RuntimeError, match="import failed"):
|
|
await plugin._handle_deploy(1, "env-1", log=log, git_log=log, superset_log=log)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_deploy_missing_env_id(self):
|
|
"""Negative: empty env_id raises ValueError."""
|
|
plugin, _ = _make_plugin()
|
|
log = _mock_log()
|
|
with pytest.raises(ValueError, match="Target environment ID required"):
|
|
await plugin._handle_deploy(1, None, log=log, git_log=log, superset_log=log)
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_deploy_env_not_found(self):
|
|
"""Negative: env_id not in config manager."""
|
|
plugin, mock_cm = _make_plugin()
|
|
log = _mock_log()
|
|
mock_cm.get_environment.return_value = None
|
|
|
|
with patch('src.plugins.git_plugin.run_blocking') as mock_run_blocking:
|
|
mock_repo = MagicMock()
|
|
mock_repo.working_dir = tempfile.mkdtemp()
|
|
|
|
async def rb_side(kind='file', fn=None, **kwargs):
|
|
if kind == 'git' and fn == plugin.git_service.get_repo:
|
|
return mock_repo
|
|
return None
|
|
|
|
mock_run_blocking.side_effect = rb_side
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
await plugin._handle_deploy(1, "nonexistent-env", log=log, git_log=log, superset_log=log)
|
|
|
|
|
|
class TestGetEnv:
|
|
"""Verify _get_env method — all paths."""
|
|
|
|
def test_get_env_from_config(self):
|
|
"""Happy: get env from ConfigManager."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_env = MagicMock()
|
|
mock_env.name = "Test Env"
|
|
mock_env.id = "env-1"
|
|
mock_cm.get_environment.return_value = mock_env
|
|
|
|
result = plugin._get_env("env-1")
|
|
assert result.name == "Test Env"
|
|
|
|
def test_get_env_not_found_raises(self):
|
|
"""Negative: env not found in config or DB when env_id given."""
|
|
from src.plugins.git_plugin import GitPlugin as RealGitPlugin
|
|
mock_cm = MagicMock()
|
|
mock_cm.get_environment.return_value = None
|
|
mock_session = MagicMock()
|
|
mock_session.query.return_value.filter.return_value.first.return_value = None
|
|
with patch('src.services.git._base.GitServiceBase._ensure_base_path_exists'), \
|
|
patch('src.services.git._base.SessionLocal', return_value=mock_session), \
|
|
patch('src.dependencies.config_manager', mock_cm):
|
|
plugin = RealGitPlugin()
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
db_filter = MagicMock()
|
|
db_filter.first.return_value = None
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
|
|
with pytest.raises(ValueError, match="not found"):
|
|
plugin._get_env("bad-env")
|
|
|
|
def test_get_env_db_fallback(self):
|
|
"""Happy: get env from DB when not in config."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_cm.get_environment.return_value = None
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
mock_db_env = MagicMock()
|
|
mock_db_env.id = "db-env-1"
|
|
mock_db_env.name = "DB Env"
|
|
mock_db_env.superset_url = "https://superset.example.com"
|
|
mock_db_env.superset_token = "token123"
|
|
|
|
db_filter = MagicMock()
|
|
db_filter.first.return_value = mock_db_env
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
|
|
with patch('src.core.config_models.Environment') as MockEnv:
|
|
MockEnv.return_value.name = "DB Env"
|
|
result = plugin._get_env("db-env-1")
|
|
assert result.name == "DB Env"
|
|
|
|
def test_get_env_first_active_db(self):
|
|
"""Happy: get first active env from DB when no env_id."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_cm.get_environment.return_value = None
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
mock_db_env = MagicMock()
|
|
mock_db_env.id = "active-1"
|
|
mock_db_env.name = "Active Env"
|
|
mock_db_env.superset_url = "https://example.com"
|
|
mock_db_env.superset_token = "token"
|
|
|
|
# First filter (is_active) returns the mock
|
|
# Second filter (first env) never reached
|
|
db_filter = MagicMock()
|
|
db_filter.first.return_value = mock_db_env
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
|
|
with patch('src.core.config_models.Environment'):
|
|
result = plugin._get_env(None)
|
|
assert result is not None
|
|
|
|
def test_get_env_fallback_to_first_config(self):
|
|
"""Happy: fallback to first env from config when no env_id and no DB env."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_cm.get_environment.return_value = None
|
|
|
|
mock_env_list = [MagicMock(), MagicMock()]
|
|
mock_env_list[0].name = "First Env"
|
|
mock_cm.get_environments.return_value = mock_env_list
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
db_filter = MagicMock()
|
|
db_filter.first.side_effect = [None, None] # no active, no first
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
mock_db.query.return_value.first.return_value = None # plain .first() returns None
|
|
|
|
result = plugin._get_env(None)
|
|
assert result.name == "First Env"
|
|
|
|
def test_get_env_no_envs_configured(self):
|
|
"""Negative: no environments at all raises ValueError."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_cm.get_environment.return_value = None
|
|
mock_cm.get_environments.return_value = []
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
db_filter = MagicMock()
|
|
db_filter.first.side_effect = [None, None]
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
mock_db.query.return_value.first.return_value = None # plain .first() returns None
|
|
|
|
with pytest.raises(ValueError, match="No environments configured"):
|
|
plugin._get_env(None)
|
|
|
|
def test_get_env_cm_config_id_present(self):
|
|
"""Edge: env_id given but not found in config, but found in DB."""
|
|
plugin, mock_cm = _make_plugin()
|
|
mock_cm.get_environment.return_value = None
|
|
|
|
with patch('src.core.database.SessionLocal') as MockSession:
|
|
mock_db = MagicMock()
|
|
MockSession.return_value = mock_db
|
|
mock_db_env = MagicMock()
|
|
mock_db_env.id = "db-1"
|
|
mock_db_env.name = "DB Found"
|
|
mock_db_env.superset_url = "https://example.com"
|
|
mock_db_env.superset_token = "tok"
|
|
|
|
db_filter = MagicMock()
|
|
db_filter.first.return_value = mock_db_env
|
|
mock_db.query.return_value.filter.return_value = db_filter
|
|
|
|
with patch('src.core.config_models.Environment') as MockEnv:
|
|
MockEnv.return_value.name = "DB Found"
|
|
result = plugin._get_env("db-1")
|
|
assert result.name == "DB Found"
|
|
# #endregion Test.GitPlugin
|