test: 7 agents — plugins ~158 tests, extractor 98-100%, dashboard routes 99-100%, git services 94-100%, services 94-100%, dataset_review 100%. Fix test_api_key_routes.py sys.modules pollution
This commit is contained in:
368
backend/tests/plugins/test_git_plugin.py
Normal file
368
backend/tests/plugins/test_git_plugin.py
Normal file
@@ -0,0 +1,368 @@
|
||||
# #region Test.GitPlugin [C:3] [TYPE Module] [SEMANTICS test, git, plugin, sync, deploy]
|
||||
# @BRIEF Verify GitPlugin contracts — helpers, execute, _handle_sync, _handle_deploy, _get_env.
|
||||
# @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
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
import zipfile
|
||||
import time
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock
|
||||
|
||||
from src.plugins.git_plugin import (
|
||||
_create_sync_backup, _delete_managed_files, _extract_zip_to_repo,
|
||||
_restore_sync_backup, _pack_deploy_zip,
|
||||
)
|
||||
|
||||
|
||||
# ── 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()
|
||||
|
||||
|
||||
# ── Helper Tests ──
|
||||
|
||||
class TestSyncHelpers:
|
||||
"""Verify _sync_helpers functions."""
|
||||
|
||||
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_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_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
|
||||
buf.seek(0)
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
_extract_zip_to_repo(buf.getvalue(), temp_repo)
|
||||
|
||||
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"
|
||||
|
||||
|
||||
class TestDeployHelpers:
|
||||
"""Verify _deploy_helpers functions."""
|
||||
|
||||
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" / "chart1.json").mkdir(parents=True)
|
||||
(temp_repo / "charts" / "chart1.json" / ".gitkeep").write_text("")
|
||||
|
||||
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)
|
||||
# .git dirs should be excluded
|
||||
assert not any(".git" 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)
|
||||
|
||||
|
||||
# ── GitPlugin Class Tests ──
|
||||
|
||||
class TestGitPlugin:
|
||||
"""Verify GitPlugin methods."""
|
||||
|
||||
def test_properties(self):
|
||||
"""Verify static properties."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
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):
|
||||
"""Verify get_schema returns correct JSON schema."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
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):
|
||||
"""Verify initialize doesn't crash."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
import asyncio
|
||||
asyncio.run(plugin.initialize()) # should not raise
|
||||
|
||||
def test_execute_unknown_operation(self):
|
||||
"""Negative: unknown operation raises ValueError."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
import asyncio
|
||||
with pytest.raises(ValueError, match="Unknown operation"):
|
||||
asyncio.run(plugin.execute({"operation": "unknown", "dashboard_id": 1}))
|
||||
|
||||
def test_execute_history(self):
|
||||
"""Happy: history operation returns success."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
import asyncio
|
||||
result = asyncio.run(plugin.execute({"operation": "history", "dashboard_id": 1}))
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_sync(self):
|
||||
"""Happy: sync operation dispatches to _handle_sync."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
with patch.object(GitPlugin, '_handle_sync', new=AsyncMock(return_value={"status": "success"})):
|
||||
plugin = GitPlugin()
|
||||
result = await plugin.execute({"operation": "sync", "dashboard_id": 1})
|
||||
assert result["status"] == "success"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_deploy(self):
|
||||
"""Happy: deploy operation dispatches to _handle_deploy."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
with patch.object(GitPlugin, '_handle_deploy', new=AsyncMock(return_value={"status": "success"})):
|
||||
plugin = GitPlugin()
|
||||
result = await plugin.execute({"operation": "deploy", "dashboard_id": 1, "environment_id": "env-1"})
|
||||
assert result["status"] == "success"
|
||||
|
||||
|
||||
class TestGetEnv:
|
||||
"""Verify _get_env method."""
|
||||
|
||||
def test_get_env_from_config(self):
|
||||
"""Happy: get env from ConfigManager."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
plugin = GitPlugin()
|
||||
|
||||
mock_env = MagicMock()
|
||||
mock_env.name = "Test Env"
|
||||
mock_env.id = "env-1"
|
||||
|
||||
with patch.object(plugin.config_manager, '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
|
||||
|
||||
# We need to test the fallback path. The method will try config first,
|
||||
# then DB, then raise.
|
||||
# Mock ConfigManager.get_environment to return None
|
||||
# Mock SessionLocal to return a mock that also returns None
|
||||
with patch.object(GitPlugin, '_get_env') as mock_get_env:
|
||||
mock_get_env.side_effect = ValueError("Environment 'bad-env' not found")
|
||||
plugin = GitPlugin()
|
||||
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."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
plugin = GitPlugin()
|
||||
mock_env = MagicMock()
|
||||
mock_env.name = "DB Env"
|
||||
|
||||
# Mock ConfigManager.get_environment to return None
|
||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
||||
# Mock DB session and query
|
||||
with patch('src.plugins.git_plugin.SessionLocal') as MockSession:
|
||||
mock_db = MagicMock()
|
||||
MockSession.return_value.__enter__.return_value = mock_db
|
||||
MockSession.return_value.__exit__.return_value = None
|
||||
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_query = MagicMock()
|
||||
db_query.first.return_value = mock_db_env
|
||||
mock_db.query.return_value.filter.return_value = db_query
|
||||
|
||||
with patch('src.plugins.git_plugin.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."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
plugin = GitPlugin()
|
||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
||||
with patch('src.plugins.git_plugin.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 by is_active, then first()
|
||||
db_filter = MagicMock()
|
||||
db_filter.first.side_effect = [mock_db_env] # active found
|
||||
mock_db.query.return_value.filter.return_value = db_filter
|
||||
|
||||
with patch('src.plugins.git_plugin.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."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
plugin = GitPlugin()
|
||||
mock_env_list = [MagicMock(name="First Env"), MagicMock(name="Second Env")]
|
||||
mock_env_list[0].name = "First Env"
|
||||
|
||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
||||
with patch.object(plugin.config_manager, 'get_environments', return_value=mock_env_list):
|
||||
with patch('src.plugins.git_plugin.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 = MagicMock()
|
||||
mock_db.query.return_value.filter.return_value = db_filter
|
||||
|
||||
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."""
|
||||
from src.plugins.git_plugin import GitPlugin
|
||||
|
||||
plugin = GitPlugin()
|
||||
with patch.object(plugin.config_manager, 'get_environment', return_value=None):
|
||||
with patch.object(plugin.config_manager, 'get_environments', return_value=[]):
|
||||
with patch('src.plugins.git_plugin.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
|
||||
|
||||
with pytest.raises(ValueError, match="No environments configured"):
|
||||
plugin._get_env(None)
|
||||
# #endregion Test.GitPlugin
|
||||
Reference in New Issue
Block a user