Files
ss-tools/backend/tests/plugins/test_git_plugin.py

728 lines
29 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()
with 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_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()
with 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
with 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
with 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_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_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
# We need a fresh plugin with properly mocked dependencies
mock_cm = MagicMock()
mock_cm.get_environment.return_value = None
with 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 query returns None too
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