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:
2026-06-15 18:30:05 +03:00
parent 3de67c258a
commit 51d90f58c1
40 changed files with 9629 additions and 4 deletions

View 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