Testing: Stabilized test suite and achieved 93% total coverage.
- Fixed CLI tests by patching local imports at source (psutil, os, Path). - Fixed Collector tests by globalizing docker.from_env mock. - Stabilized SSE stream tests via AsyncMock and immediate generator termination to prevent hangs. - Achieved >80% coverage on CLI (84%), Collector (97%), and DB Repository (100%). - Implemented SMTP Relay service tests (100%).
This commit is contained in:
309
tests/test_deployer.py
Normal file
309
tests/test_deployer.py
Normal file
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Tests for decnet/engine/deployer.py
|
||||
|
||||
Covers _compose, _compose_with_retry, _sync_logging_helper,
|
||||
deploy (dry-run and mocked), teardown, status, and _print_status.
|
||||
All Docker and subprocess calls are mocked.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch, call
|
||||
|
||||
import pytest
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _decky(name: str = "decky-01", ip: str = "192.168.1.10",
|
||||
services: list[str] | None = None) -> DeckyConfig:
|
||||
return DeckyConfig(
|
||||
name=name, ip=ip, services=services or ["ssh"],
|
||||
distro="debian", base_image="debian", hostname="test-host",
|
||||
build_base="debian:bookworm-slim", nmap_os="linux",
|
||||
)
|
||||
|
||||
|
||||
def _config(deckies: list[DeckyConfig] | None = None, ipvlan: bool = False) -> DecnetConfig:
|
||||
return DecnetConfig(
|
||||
mode="unihost", interface="eth0", subnet="192.168.1.0/24",
|
||||
gateway="192.168.1.1", deckies=deckies or [_decky()],
|
||||
ipvlan=ipvlan,
|
||||
)
|
||||
|
||||
|
||||
# ── _compose ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestCompose:
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_compose_constructs_correct_command(self, mock_run):
|
||||
from decnet.engine.deployer import _compose
|
||||
_compose("up", "-d", compose_file=Path("test.yml"))
|
||||
mock_run.assert_called_once()
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:4] == ["docker", "compose", "-f", "test.yml"]
|
||||
assert "up" in cmd
|
||||
assert "-d" in cmd
|
||||
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_compose_passes_env(self, mock_run):
|
||||
from decnet.engine.deployer import _compose
|
||||
_compose("build", env={"DOCKER_BUILDKIT": "1"})
|
||||
_, kwargs = mock_run.call_args
|
||||
assert "DOCKER_BUILDKIT" in kwargs["env"]
|
||||
|
||||
|
||||
# ── _compose_with_retry ───────────────────────────────────────────────────────
|
||||
|
||||
class TestComposeWithRetry:
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_success_first_try(self, mock_run):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
||||
_compose_with_retry("up", "-d") # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_transient_failure_retries(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="temporary error")
|
||||
ok_result = MagicMock(returncode=0, stdout="ok", stderr="")
|
||||
mock_run.side_effect = [fail_result, ok_result]
|
||||
_compose_with_retry("up", retries=3)
|
||||
assert mock_run.call_count == 2
|
||||
mock_sleep.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_permanent_error_no_retry(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="manifest unknown error")
|
||||
mock_run.return_value = fail_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("pull", retries=3)
|
||||
assert mock_run.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("decnet.engine.deployer.time.sleep")
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_max_retries_exhausted(self, mock_run, mock_sleep):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
fail_result = MagicMock(returncode=1, stdout="", stderr="connection refused")
|
||||
mock_run.return_value = fail_result
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", retries=2)
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
@patch("decnet.engine.deployer.subprocess.run")
|
||||
def test_stdout_printed_on_success(self, mock_run, capsys):
|
||||
from decnet.engine.deployer import _compose_with_retry
|
||||
mock_run.return_value = MagicMock(returncode=0, stdout="done\n", stderr="")
|
||||
_compose_with_retry("build")
|
||||
captured = capsys.readouterr()
|
||||
assert "done" in captured.out
|
||||
|
||||
|
||||
# ── _sync_logging_helper ─────────────────────────────────────────────────────
|
||||
|
||||
class TestSyncLoggingHelper:
|
||||
@patch("decnet.engine.deployer.shutil.copy2")
|
||||
@patch("decnet.engine.deployer._CANONICAL_LOGGING")
|
||||
def test_copies_when_file_differs(self, mock_canonical, mock_copy):
|
||||
from decnet.engine.deployer import _sync_logging_helper
|
||||
mock_svc = MagicMock()
|
||||
mock_svc.dockerfile_context.return_value = Path("/tmp/test_ctx")
|
||||
mock_canonical.__truediv__ = Path.__truediv__
|
||||
|
||||
with patch("decnet.services.registry.get_service", return_value=mock_svc):
|
||||
with patch("pathlib.Path.exists", return_value=False):
|
||||
config = _config()
|
||||
_sync_logging_helper(config)
|
||||
|
||||
|
||||
# ── deploy ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestDeploy:
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_dry_run_no_containers(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, dry_run=True)
|
||||
mock_create.assert_not_called()
|
||||
mock_retry.assert_not_called()
|
||||
mock_save.assert_not_called()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_macvlan_deploy(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config(ipvlan=False)
|
||||
deploy(config)
|
||||
mock_create.assert_called_once()
|
||||
mock_setup.assert_called_once()
|
||||
mock_save.assert_called_once()
|
||||
mock_retry.assert_called()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_ipvlan")
|
||||
@patch("decnet.engine.deployer.create_ipvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_ipvlan_deploy(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config(ipvlan=True)
|
||||
deploy(config)
|
||||
mock_create.assert_called_once()
|
||||
mock_setup.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_parallel_build(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, parallel=True)
|
||||
# Parallel mode calls _compose_with_retry for "build" and "up" separately
|
||||
calls = mock_retry.call_args_list
|
||||
assert any("build" in str(c) for c in calls)
|
||||
|
||||
@patch("decnet.engine.deployer._print_status")
|
||||
@patch("decnet.engine.deployer._compose_with_retry")
|
||||
@patch("decnet.engine.deployer.save_state")
|
||||
@patch("decnet.engine.deployer.write_compose", return_value=Path("test.yml"))
|
||||
@patch("decnet.engine.deployer._sync_logging_helper")
|
||||
@patch("decnet.engine.deployer.setup_host_macvlan")
|
||||
@patch("decnet.engine.deployer.create_macvlan_network")
|
||||
@patch("decnet.engine.deployer.get_host_ip", return_value="192.168.1.2")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
def test_no_cache_build(self, mock_docker, mock_range, mock_hip,
|
||||
mock_create, mock_setup, mock_sync,
|
||||
mock_compose, mock_save, mock_retry, mock_print):
|
||||
from decnet.engine.deployer import deploy
|
||||
config = _config()
|
||||
deploy(config, no_cache=True)
|
||||
calls = mock_retry.call_args_list
|
||||
assert any("--no-cache" in str(c) for c in calls)
|
||||
|
||||
|
||||
# ── teardown ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestTeardown:
|
||||
@patch("decnet.engine.deployer.load_state", return_value=None)
|
||||
def test_no_state(self, mock_load):
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.clear_state")
|
||||
@patch("decnet.engine.deployer.remove_macvlan_network")
|
||||
@patch("decnet.engine.deployer.teardown_host_macvlan")
|
||||
@patch("decnet.engine.deployer._compose")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_full_teardown_macvlan(self, mock_load, mock_docker, mock_range,
|
||||
mock_compose, mock_td_macvlan, mock_rm_net,
|
||||
mock_clear):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown()
|
||||
mock_compose.assert_called_once()
|
||||
mock_td_macvlan.assert_called_once()
|
||||
mock_rm_net.assert_called_once()
|
||||
mock_clear.assert_called_once()
|
||||
|
||||
@patch("decnet.engine.deployer.clear_state")
|
||||
@patch("decnet.engine.deployer.remove_macvlan_network")
|
||||
@patch("decnet.engine.deployer.teardown_host_ipvlan")
|
||||
@patch("decnet.engine.deployer._compose")
|
||||
@patch("decnet.engine.deployer.ips_to_range", return_value="192.168.1.10/32")
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_full_teardown_ipvlan(self, mock_load, mock_docker, mock_range,
|
||||
mock_compose, mock_td_ipvlan, mock_rm_net,
|
||||
mock_clear):
|
||||
config = _config(ipvlan=True)
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
from decnet.engine.deployer import teardown
|
||||
teardown()
|
||||
mock_td_ipvlan.assert_called_once()
|
||||
|
||||
|
||||
# ── status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
class TestStatus:
|
||||
@patch("decnet.engine.deployer.load_state", return_value=None)
|
||||
def test_no_state(self, mock_load):
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_with_running_containers(self, mock_load, mock_docker):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
mock_container = MagicMock()
|
||||
mock_container.name = "decky-01-ssh"
|
||||
mock_container.status = "running"
|
||||
mock_docker.return_value.containers.list.return_value = [mock_container]
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
@patch("decnet.engine.deployer.docker.from_env")
|
||||
@patch("decnet.engine.deployer.load_state")
|
||||
def test_with_absent_containers(self, mock_load, mock_docker):
|
||||
config = _config()
|
||||
mock_load.return_value = (config, Path("test.yml"))
|
||||
mock_docker.return_value.containers.list.return_value = []
|
||||
from decnet.engine.deployer import status
|
||||
status() # should not raise
|
||||
|
||||
|
||||
# ── _print_status ─────────────────────────────────────────────────────────────
|
||||
|
||||
class TestPrintStatus:
|
||||
def test_renders_table(self):
|
||||
from decnet.engine.deployer import _print_status
|
||||
config = _config(deckies=[_decky(), _decky("decky-02", "192.168.1.11")])
|
||||
_print_status(config) # should not raise
|
||||
Reference in New Issue
Block a user