# #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