Refactor: implemented Repository Factory and Async Mutator Engine. Decoupled storage logic and enforced Dependency Injection across CLI and Web API. Updated documentation.
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 13s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Failing after 54s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
Some checks failed
CI / Lint (ruff) (push) Successful in 12s
CI / SAST (bandit) (push) Successful in 13s
CI / Dependency audit (pip-audit) (push) Successful in 22s
CI / Test (Standard) (3.11) (push) Failing after 54s
CI / Test (Standard) (3.12) (push) Successful in 1m35s
CI / Test (Live) (3.11) (push) Has been skipped
CI / Test (Fuzz) (3.11) (push) Has been skipped
CI / Merge dev → testing (push) Has been skipped
CI / Prepare Merge to Main (push) Has been skipped
CI / Finalize Merge to Main (push) Has been skipped
This commit is contained in:
@@ -2,10 +2,9 @@
|
||||
Tests for decnet.mutator — mutation engine, retry logic, due-time scheduling.
|
||||
All subprocess and state I/O is mocked; no Docker or filesystem access.
|
||||
"""
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock, patch, AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -41,9 +40,131 @@ def _make_config(deckies=None, mutate_interval=30):
|
||||
mutate_interval=mutate_interval,
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_repo():
|
||||
repo = AsyncMock()
|
||||
repo.get_state.return_value = None
|
||||
return repo
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compose_with_retry
|
||||
# mutate_decky
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMutateDecky:
|
||||
def _patch_io(self):
|
||||
"""Return a context manager that mocks all other I/O in mutate_decky."""
|
||||
return (
|
||||
patch("decnet.mutator.engine.write_compose"),
|
||||
patch("decnet.mutator.engine._compose_with_retry", new_callable=AsyncMock),
|
||||
)
|
||||
|
||||
async def test_returns_false_when_no_state(self, mock_repo):
|
||||
mock_repo.get_state.return_value = None
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is False
|
||||
|
||||
async def test_returns_false_when_decky_not_found(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
assert await mutate_decky("nonexistent", repo=mock_repo) is False
|
||||
|
||||
async def test_returns_true_on_success(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is True
|
||||
|
||||
async def test_saves_state_after_mutation(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
async def test_regenerates_compose_after_mutation(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose") as mock_compose, \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
mock_compose.assert_called_once()
|
||||
|
||||
async def test_returns_false_on_compose_failure(self, mock_repo):
|
||||
cfg = _make_config()
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", side_effect=Exception("docker fail")):
|
||||
assert await mutate_decky("decky-01", repo=mock_repo) is False
|
||||
|
||||
async def test_mutation_changes_services(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky(services=["ssh"])])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
|
||||
# Check that set_state was called with a config where services might have changed
|
||||
call_args = mock_repo.set_state.await_args[0]
|
||||
new_config_dict = call_args[1]["config"]
|
||||
new_services = new_config_dict["deckies"][0]["services"]
|
||||
assert isinstance(new_services, list)
|
||||
assert len(new_services) >= 1
|
||||
|
||||
async def test_updates_last_mutated_timestamp(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky(last_mutated=0.0)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
before = time.time()
|
||||
with patch("decnet.mutator.engine.write_compose"), \
|
||||
patch("anyio.to_thread.run_sync", new_callable=AsyncMock):
|
||||
await mutate_decky("decky-01", repo=mock_repo)
|
||||
|
||||
call_args = mock_repo.set_state.await_args[0]
|
||||
new_last_mutated = call_args[1]["config"]["deckies"][0]["last_mutated"]
|
||||
assert new_last_mutated >= before
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestMutateAll:
|
||||
async def test_no_state_returns_early(self, mock_repo):
|
||||
mock_repo.get_state.return_value = None
|
||||
with patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
await mutate_all(repo=mock_repo)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
async def test_force_mutates_all_deckies(self, mock_repo):
|
||||
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky", new_callable=AsyncMock, return_value=True) as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=True)
|
||||
assert mock_mutate.call_count == 2
|
||||
|
||||
async def test_skips_decky_not_yet_due(self, mock_repo):
|
||||
# last_mutated = now, interval = 30 min → not due
|
||||
now = time.time()
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
async def test_mutates_decky_that_is_due(self, mock_repo):
|
||||
# last_mutated = 2 hours ago, interval = 30 min → due
|
||||
old_ts = time.time() - 7200
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
|
||||
mock_repo.get_state.return_value = {"config": cfg.model_dump(), "compose_path": "c.yml"}
|
||||
with patch("decnet.mutator.engine.mutate_decky", new_callable=AsyncMock, return_value=True) as mock_mutate:
|
||||
await mutate_all(repo=mock_repo, force=False)
|
||||
mock_mutate.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _compose_with_retry (Sync tests, keep as is or minimal update)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestComposeWithRetry:
|
||||
@@ -60,149 +181,3 @@ class TestComposeWithRetry:
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
assert mock_run.call_count == 2
|
||||
|
||||
def test_raises_after_all_retries_exhausted(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="hard error")
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep"):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", "-d", compose_file=Path("compose.yml"), retries=3)
|
||||
|
||||
def test_exponential_backoff(self):
|
||||
fail = MagicMock(returncode=1, stdout="", stderr="")
|
||||
sleep_calls = []
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=fail), \
|
||||
patch("decnet.engine.deployer.time.sleep", side_effect=lambda d: sleep_calls.append(d)):
|
||||
with pytest.raises(subprocess.CalledProcessError):
|
||||
_compose_with_retry("up", compose_file=Path("c.yml"), retries=3, delay=1.0)
|
||||
assert sleep_calls == [1.0, 2.0]
|
||||
|
||||
def test_correct_command_structure(self):
|
||||
ok = MagicMock(returncode=0, stdout="")
|
||||
with patch("decnet.engine.deployer.subprocess.run", return_value=ok) as mock_run:
|
||||
_compose_with_retry("up", "-d", "--remove-orphans",
|
||||
compose_file=Path("/tmp/compose.yml"))
|
||||
cmd = mock_run.call_args[0][0]
|
||||
assert cmd[:3] == ["docker", "compose", "-f"]
|
||||
assert "up" in cmd
|
||||
assert "--remove-orphans" in cmd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_decky
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMutateDecky:
|
||||
def _patch(self, config=None, compose_path=Path("compose.yml")):
|
||||
"""Return a context manager that mocks all I/O in mutate_decky."""
|
||||
cfg = config or _make_config()
|
||||
return (
|
||||
patch("decnet.mutator.engine.load_state", return_value=(cfg, compose_path)),
|
||||
patch("decnet.mutator.engine.save_state"),
|
||||
patch("decnet.mutator.engine.write_compose"),
|
||||
patch("decnet.mutator.engine._compose_with_retry"),
|
||||
)
|
||||
|
||||
def test_returns_false_when_no_state(self):
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_returns_false_when_decky_not_found(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
assert mutate_decky("nonexistent") is False
|
||||
|
||||
def test_returns_true_on_success(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
assert mutate_decky("decky-01") is True
|
||||
|
||||
def test_saves_state_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], patch("decnet.mutator.engine.save_state") as mock_save, p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_save.assert_called_once()
|
||||
|
||||
def test_regenerates_compose_after_mutation(self):
|
||||
p = self._patch()
|
||||
with p[0], p[1], patch("decnet.mutator.engine.write_compose") as mock_compose, p[3]:
|
||||
mutate_decky("decky-01")
|
||||
mock_compose.assert_called_once()
|
||||
|
||||
def test_returns_false_on_compose_failure(self):
|
||||
p = self._patch()
|
||||
err = subprocess.CalledProcessError(1, "docker", "", "compose failed")
|
||||
with p[0], p[1], p[2], patch("decnet.mutator.engine._compose_with_retry", side_effect=err):
|
||||
assert mutate_decky("decky-01") is False
|
||||
|
||||
def test_mutation_changes_services(self):
|
||||
cfg = _make_config(deckies=[_make_decky(services=["ssh"])])
|
||||
p = self._patch(config=cfg)
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
# Services may have changed (or stayed the same after 20 attempts)
|
||||
assert isinstance(cfg.deckies[0].services, list)
|
||||
assert len(cfg.deckies[0].services) >= 1
|
||||
|
||||
def test_updates_last_mutated_timestamp(self):
|
||||
cfg = _make_config(deckies=[_make_decky(last_mutated=0.0)])
|
||||
p = self._patch(config=cfg)
|
||||
before = time.time()
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
mutate_decky("decky-01")
|
||||
assert cfg.deckies[0].last_mutated >= before
|
||||
|
||||
def test_archetype_constrains_service_pool(self):
|
||||
"""A decky with an archetype must only mutate within its service pool."""
|
||||
cfg = _make_config(deckies=[_make_decky(archetype="workstation", services=["rdp"])])
|
||||
p = self._patch(config=cfg)
|
||||
with p[0], p[1], p[2], p[3]:
|
||||
result = mutate_decky("decky-01")
|
||||
assert result is True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# mutate_all
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestMutateAll:
|
||||
def test_no_state_returns_early(self):
|
||||
with patch("decnet.mutator.engine.load_state", return_value=None), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all()
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
def test_force_mutates_all_deckies(self):
|
||||
cfg = _make_config(deckies=[_make_decky("d1"), _make_decky("d2")])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=True)
|
||||
assert mock_mutate.call_count == 2
|
||||
|
||||
def test_skips_decky_not_yet_due(self):
|
||||
# last_mutated = now, interval = 30 min → not due
|
||||
now = time.time()
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=now)])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
def test_mutates_decky_that_is_due(self):
|
||||
# last_mutated = 2 hours ago, interval = 30 min → due
|
||||
old_ts = time.time() - 7200
|
||||
cfg = _make_config(deckies=[_make_decky(mutate_interval=30, last_mutated=old_ts)])
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky", return_value=True) as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_called_once_with("decky-01")
|
||||
|
||||
def test_skips_decky_with_no_interval_and_no_force(self):
|
||||
cfg = _make_config(
|
||||
deckies=[_make_decky(mutate_interval=None)],
|
||||
mutate_interval=None,
|
||||
)
|
||||
with patch("decnet.mutator.engine.load_state", return_value=(cfg, Path("c.yml"))), \
|
||||
patch("decnet.mutator.engine.mutate_decky") as mock_mutate:
|
||||
mutate_all(force=False)
|
||||
mock_mutate.assert_not_called()
|
||||
|
||||
Reference in New Issue
Block a user