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:
@@ -1,11 +1,9 @@
|
||||
"""
|
||||
Tests for the mutate interval API endpoint.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import httpx
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
from decnet.config import DeckyConfig, DecnetConfig
|
||||
|
||||
@@ -37,19 +35,20 @@ class TestMutateInterval:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_no_active_deployment(self, client: httpx.AsyncClient, auth_token: str):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state", return_value=None):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = None
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 60},
|
||||
)
|
||||
assert resp.status_code == 500
|
||||
assert resp.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_decky_not_found(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/nonexistent/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
@@ -60,30 +59,26 @@ class TestMutateInterval:
|
||||
@pytest.mark.asyncio
|
||||
async def test_successful_interval_update(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state") as mock_save:
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 120},
|
||||
)
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": 120},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["message"] == "Mutation interval updated"
|
||||
mock_save.assert_called_once()
|
||||
# Verify the interval was actually updated on the decky config
|
||||
assert config.deckies[0].mutate_interval == 120
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_null_interval_removes_mutation(self, client: httpx.AsyncClient, auth_token: str):
|
||||
config = _config()
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.load_state",
|
||||
return_value=(config, Path("test.yml"))):
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.save_state"):
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": None},
|
||||
)
|
||||
with patch("decnet.web.router.fleet.api_mutate_interval.repo", new_callable=AsyncMock) as mock_repo:
|
||||
mock_repo.get_state.return_value = {"config": config.model_dump(), "compose_path": "c.yml"}
|
||||
resp = await client.put(
|
||||
"/api/v1/deckies/decky-01/mutate-interval",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"mutate_interval": None},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert config.deckies[0].mutate_interval is None
|
||||
mock_repo.set_state.assert_awaited_once()
|
||||
|
||||
@@ -33,11 +33,11 @@ async def test_fuzz_get_logs(client: httpx.AsyncClient, auth_token: str, limit:
|
||||
_params: dict[str, Any] = {"limit": limit, "offset": offset}
|
||||
if search is not None:
|
||||
_params["search"] = search
|
||||
|
||||
|
||||
_response: httpx.Response = await client.get(
|
||||
"/api/v1/logs",
|
||||
params=_params,
|
||||
headers={"Authorization": f"Bearer {auth_token}"}
|
||||
)
|
||||
|
||||
|
||||
assert _response.status_code in (200, 422)
|
||||
|
||||
@@ -9,13 +9,13 @@ import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from freezegun import freeze_time
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.factory import get_repository
|
||||
from ..conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
return SQLiteRepository(db_path=str(tmp_path / "histogram_test.db"))
|
||||
return get_repository(db_path=str(tmp_path / "histogram_test.db"))
|
||||
|
||||
|
||||
def _log(decky="d", service="ssh", ip="1.2.3.4", timestamp=None):
|
||||
|
||||
@@ -21,7 +21,7 @@ class TestStreamEvents:
|
||||
# We force the generator to exit immediately by making the first awaitable raise
|
||||
with patch("decnet.web.router.stream.api_stream_events.repo") as mock_repo:
|
||||
mock_repo.get_max_log_id = AsyncMock(side_effect=StopAsyncIteration)
|
||||
|
||||
|
||||
# This will hit the 'except Exception' or just exit the generator
|
||||
resp = await client.get(
|
||||
"/api/v1/stream",
|
||||
|
||||
@@ -1,18 +1,17 @@
|
||||
"""
|
||||
Direct async tests for SQLiteRepository.
|
||||
These exercise the DB layer without going through the HTTP stack,
|
||||
covering DEBT-006 (zero test coverage on the database layer).
|
||||
Direct async tests for the configured Repository implementation.
|
||||
These exercise the DB layer without going through the HTTP stack.
|
||||
"""
|
||||
import json
|
||||
import pytest
|
||||
from hypothesis import given, settings, strategies as st
|
||||
from decnet.web.db.sqlite.repository import SQLiteRepository
|
||||
from decnet.web.db.factory import get_repository
|
||||
from .conftest import _FUZZ_SETTINGS
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def repo(tmp_path):
|
||||
return SQLiteRepository(db_path=str(tmp_path / "test.db"))
|
||||
return get_repository(db_path=str(tmp_path / "test.db"))
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -11,16 +11,82 @@ replace the checks list with the default (remove the argument) for full complian
|
||||
Requires DECNET_DEVELOPER=true (set in tests/conftest.py) to expose /openapi.json.
|
||||
"""
|
||||
import pytest
|
||||
import schemathesis
|
||||
from hypothesis import settings
|
||||
from schemathesis.checks import not_a_server_error
|
||||
from decnet.web.api import app
|
||||
import schemathesis as st
|
||||
from hypothesis import settings, Verbosity
|
||||
from decnet.web.auth import create_access_token
|
||||
|
||||
schema = schemathesis.openapi.from_asgi("/openapi.json", app)
|
||||
import subprocess
|
||||
import socket
|
||||
import sys
|
||||
import atexit
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# Configuration for the automated live server
|
||||
LIVE_PORT = 8008
|
||||
LIVE_SERVER_URL = f"http://127.0.0.1:{LIVE_PORT}"
|
||||
TEST_SECRET = "test-secret-for-automated-fuzzing"
|
||||
|
||||
# Standardize the secret for the test process too so tokens can be verified
|
||||
import decnet.web.auth
|
||||
decnet.web.auth.SECRET_KEY = TEST_SECRET
|
||||
|
||||
# Create a valid token for an admin-like user
|
||||
TEST_TOKEN = create_access_token({"uuid": "00000000-0000-0000-0000-000000000001"})
|
||||
|
||||
@st.hook
|
||||
def before_call(context, case, *args):
|
||||
# Logged-in admin for all requests
|
||||
case.headers = case.headers or {}
|
||||
case.headers["Authorization"] = f"Bearer {TEST_TOKEN}"
|
||||
|
||||
def wait_for_port(port, timeout=10):
|
||||
start_time = time.time()
|
||||
while time.time() - start_time < timeout:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
if sock.connect_ex(('127.0.0.1', port)) == 0:
|
||||
return True
|
||||
time.sleep(0.2)
|
||||
return False
|
||||
|
||||
def start_automated_server():
|
||||
# Use the current venv's uvicorn
|
||||
uvicorn_bin = "uvicorn" if os.name != "nt" else "uvicorn.exe"
|
||||
uvicorn_path = str(Path(sys.executable).parent / uvicorn_bin)
|
||||
|
||||
# Force developer and contract test modes for the sub-process
|
||||
env = os.environ.copy()
|
||||
env["DECNET_DEVELOPER"] = "true"
|
||||
env["DECNET_CONTRACT_TEST"] = "true"
|
||||
env["DECNET_JWT_SECRET"] = TEST_SECRET
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[uvicorn_path, "decnet.web.api:app", "--host", "127.0.0.1", "--port", str(LIVE_PORT), "--log-level", "error"],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL
|
||||
)
|
||||
|
||||
# Register cleanup
|
||||
atexit.register(proc.terminate)
|
||||
|
||||
if not wait_for_port(LIVE_PORT):
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"Automated server failed to start on port {LIVE_PORT}")
|
||||
|
||||
return proc
|
||||
|
||||
# Stir up the server!
|
||||
_server_proc = start_automated_server()
|
||||
|
||||
# Now Schemathesis can pull the schema from the real network port
|
||||
schema = st.openapi.from_url(f"{LIVE_SERVER_URL}/openapi.json")
|
||||
|
||||
@pytest.mark.fuzz
|
||||
@schemathesis.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=5, deadline=None)
|
||||
@st.pytest.parametrize(api=schema)
|
||||
@settings(max_examples=3000, deadline=None, verbosity=Verbosity.debug)
|
||||
def test_schema_compliance(case):
|
||||
case.call_and_validate(checks=[not_a_server_error])
|
||||
#print(f"\n[Fuzzing] {case.method} {case.path} with query={case.query}")
|
||||
case.call_and_validate()
|
||||
#print(f" └─ Success")
|
||||
|
||||
@@ -6,6 +6,15 @@ any test file imports decnet.* — pytest loads conftest.py first.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault("DECNET_JWT_SECRET", "test-jwt-secret-not-for-production-use")
|
||||
# Expose OpenAPI schema so schemathesis can load it during tests
|
||||
os.environ.setdefault("DECNET_DEVELOPER", "true")
|
||||
os.environ["DECNET_JWT_SECRET"] = "stable-test-secret-key-at-least-32-chars-long"
|
||||
os.environ["DECNET_ADMIN_PASSWORD"] = "test-password-123"
|
||||
os.environ["DECNET_DEVELOPER"] = "true"
|
||||
os.environ["DECNET_DB_TYPE"] = "sqlite"
|
||||
|
||||
import pytest
|
||||
from typing import Any
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def standardize_auth_secret(monkeypatch: Any) -> None:
|
||||
import decnet.web.auth
|
||||
monkeypatch.setattr(decnet.web.auth, "SECRET_KEY", os.environ["DECNET_JWT_SECRET"])
|
||||
|
||||
@@ -91,7 +91,7 @@ def _publish_packet(topic: str, payload: str, qos: int = 1, pid: int = 1) -> byt
|
||||
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + pid.to_bytes(2, "big") + payload_bytes
|
||||
else:
|
||||
packet_payload = len(topic_bytes).to_bytes(2, "big") + topic_bytes + payload_bytes
|
||||
|
||||
|
||||
return bytes([byte0, len(packet_payload)]) + packet_payload
|
||||
|
||||
def _pingreq_packet() -> bytes:
|
||||
@@ -128,10 +128,10 @@ def test_subscribe_wildcard_retained(mqtt_mod):
|
||||
written.clear()
|
||||
|
||||
_send(proto, _subscribe_packet("plant/#"))
|
||||
|
||||
|
||||
assert len(written) >= 2 # At least SUBACK + some publishes
|
||||
assert written[0].startswith(b"\x90") # SUBACK
|
||||
|
||||
|
||||
combined = b"".join(written[1:])
|
||||
# Should contain some water plant topics
|
||||
assert b"plant/water/tank1/level" in combined
|
||||
|
||||
@@ -50,10 +50,10 @@ def _make_protocol(mod):
|
||||
proto = mod.SNMPProtocol()
|
||||
transport = MagicMock()
|
||||
sent: list[tuple] = []
|
||||
|
||||
|
||||
def sendto(data, addr):
|
||||
sent.append((data, addr))
|
||||
|
||||
|
||||
transport.sendto = sendto
|
||||
proto.connection_made(transport)
|
||||
sent.clear()
|
||||
@@ -104,11 +104,11 @@ def test_sysdescr_default(snmp_default):
|
||||
proto, transport, sent = _make_protocol(snmp_default)
|
||||
packet = _get_request_packet("public", 1, SYS_DESCR_OID_ENC)
|
||||
_send(proto, packet)
|
||||
|
||||
|
||||
assert len(sent) == 1
|
||||
resp, addr = sent[0]
|
||||
assert addr == ("127.0.0.1", 12345)
|
||||
|
||||
|
||||
# default sysDescr has "Ubuntu SMP" in it
|
||||
assert b"Ubuntu SMP" in resp
|
||||
|
||||
@@ -116,10 +116,10 @@ def test_sysdescr_water_plant(snmp_water_plant):
|
||||
proto, transport, sent = _make_protocol(snmp_water_plant)
|
||||
packet = _get_request_packet("public", 2, SYS_DESCR_OID_ENC)
|
||||
_send(proto, packet)
|
||||
|
||||
|
||||
assert len(sent) == 1
|
||||
resp, _ = sent[0]
|
||||
|
||||
|
||||
assert b"Debian" in resp
|
||||
|
||||
# ── Negative Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +19,8 @@ class DummyRepo(BaseRepository):
|
||||
async def add_bounty(self, d): await super().add_bounty(d)
|
||||
async def get_bounties(self, **kw): await super().get_bounties(**kw)
|
||||
async def get_total_bounties(self, **kw): await super().get_total_bounties(**kw)
|
||||
async def get_state(self, k): await super().get_state(k)
|
||||
async def set_state(self, k, v): await super().set_state(k, v)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_base_repo_coverage():
|
||||
@@ -37,3 +39,5 @@ async def test_base_repo_coverage():
|
||||
await dr.add_bounty({})
|
||||
await dr.get_bounties()
|
||||
await dr.get_total_bounties()
|
||||
await dr.get_state("k")
|
||||
await dr.set_state("k", "v")
|
||||
|
||||
@@ -7,7 +7,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
from decnet.collector import parse_rfc5424, is_service_container, is_service_event
|
||||
from decnet.collector.worker import (
|
||||
_stream_container,
|
||||
_stream_container,
|
||||
_load_service_container_names,
|
||||
log_collector_worker
|
||||
)
|
||||
@@ -291,13 +291,13 @@ class TestLogCollectorWorker:
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_initial_discovery(self, tmp_path):
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
|
||||
|
||||
mock_container = MagicMock()
|
||||
mock_container.id = "c1"
|
||||
mock_container.name = "/s-1"
|
||||
# Mock labels to satisfy is_service_container
|
||||
mock_container.labels = {"com.docker.compose.project": "decnet"}
|
||||
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.return_value = [mock_container]
|
||||
# Make events return an empty generator/iterator immediately
|
||||
@@ -310,17 +310,17 @@ class TestLogCollectorWorker:
|
||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||
except (asyncio.TimeoutError, StopIteration):
|
||||
pass
|
||||
|
||||
|
||||
# Should have tried to list and watch events
|
||||
mock_client.containers.list.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_worker_handles_events(self, tmp_path):
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
|
||||
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.return_value = []
|
||||
|
||||
|
||||
event = {
|
||||
"id": "c2",
|
||||
"Actor": {"Attributes": {"name": "s-2", "com.docker.compose.project": "decnet"}}
|
||||
@@ -333,7 +333,7 @@ class TestLogCollectorWorker:
|
||||
await asyncio.wait_for(log_collector_worker(log_file), timeout=0.1)
|
||||
except (asyncio.TimeoutError, StopIteration):
|
||||
pass
|
||||
|
||||
|
||||
mock_client.events.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -341,7 +341,7 @@ class TestLogCollectorWorker:
|
||||
log_file = str(tmp_path / "decnet.log")
|
||||
mock_client = MagicMock()
|
||||
mock_client.containers.list.side_effect = Exception("Docker down")
|
||||
|
||||
|
||||
with patch("docker.from_env", return_value=mock_client):
|
||||
# Should not raise
|
||||
await log_collector_worker(log_file)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -7,7 +7,7 @@ from decnet.services.smtp_relay import SMTPRelayService
|
||||
def test_smtp_relay_compose_fragment():
|
||||
svc = SMTPRelayService()
|
||||
fragment = svc.compose_fragment("test-decky", log_target="log-server")
|
||||
|
||||
|
||||
assert fragment["container_name"] == "test-decky-smtp_relay"
|
||||
assert fragment["environment"]["SMTP_OPEN_RELAY"] == "1"
|
||||
assert fragment["environment"]["LOG_TARGET"] == "log-server"
|
||||
@@ -15,7 +15,7 @@ def test_smtp_relay_compose_fragment():
|
||||
def test_smtp_relay_custom_cfg():
|
||||
svc = SMTPRelayService()
|
||||
fragment = svc.compose_fragment(
|
||||
"test-decky",
|
||||
"test-decky",
|
||||
service_cfg={"banner": "Welcome", "mta": "Postfix"}
|
||||
)
|
||||
assert fragment["environment"]["SMTP_BANNER"] == "Welcome"
|
||||
|
||||
Reference in New Issue
Block a user