178 lines
5.4 KiB
Python
178 lines
5.4 KiB
Python
|
|
"""Tests for the background app-install runner.
|
||
|
|
|
||
|
|
Same shape as test_catalog.py / test_updater.py: fixture reloads the
|
||
|
|
module with env-overridden paths, dockerops calls are stubbed so nothing
|
||
|
|
touches a real daemon. Asserts that state transitions happen in the
|
||
|
|
right order and that exceptions flip the state to "error" with the
|
||
|
|
message before re-raising.
|
||
|
|
"""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
from pathlib import Path
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
|
||
|
|
@pytest.fixture
|
||
|
|
def runner(tmp_path, monkeypatch):
|
||
|
|
apps = tmp_path / "apps"
|
||
|
|
apps.mkdir()
|
||
|
|
monkeypatch.setenv("FURTKA_APPS_DIR", str(apps))
|
||
|
|
monkeypatch.setenv("FURTKA_INSTALL_STATE", str(tmp_path / "install-state.json"))
|
||
|
|
monkeypatch.setenv("FURTKA_INSTALL_LOCK", str(tmp_path / "install.lock"))
|
||
|
|
|
||
|
|
import importlib
|
||
|
|
|
||
|
|
from furtka import install_runner as r
|
||
|
|
from furtka import paths as p
|
||
|
|
|
||
|
|
importlib.reload(p)
|
||
|
|
importlib.reload(r)
|
||
|
|
return r
|
||
|
|
|
||
|
|
|
||
|
|
def _write_installed_app(apps_dir: Path, name: str = "fileshare"):
|
||
|
|
app = apps_dir / name
|
||
|
|
app.mkdir()
|
||
|
|
manifest = {
|
||
|
|
"name": name,
|
||
|
|
"display_name": "Fileshare",
|
||
|
|
"version": "0.1.0",
|
||
|
|
"description": "Test fixture",
|
||
|
|
"volumes": ["files"],
|
||
|
|
"ports": [445],
|
||
|
|
"icon": "icon.svg",
|
||
|
|
}
|
||
|
|
(app / "manifest.json").write_text(json.dumps(manifest))
|
||
|
|
(app / "docker-compose.yaml").write_text("services: {}\n")
|
||
|
|
return app
|
||
|
|
|
||
|
|
|
||
|
|
def test_write_and_read_state_round_trip(runner):
|
||
|
|
runner.write_state("pulling_image", app="jellyfin")
|
||
|
|
s = runner.read_state()
|
||
|
|
assert s["stage"] == "pulling_image"
|
||
|
|
assert s["app"] == "jellyfin"
|
||
|
|
assert "updated_at" in s
|
||
|
|
|
||
|
|
|
||
|
|
def test_read_state_returns_empty_when_missing(runner):
|
||
|
|
assert runner.read_state() == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_read_state_returns_empty_on_junk(runner):
|
||
|
|
runner.state_path().parent.mkdir(parents=True, exist_ok=True)
|
||
|
|
runner.state_path().write_text("{not json")
|
||
|
|
assert runner.read_state() == {}
|
||
|
|
|
||
|
|
|
||
|
|
def test_acquire_lock_prevents_concurrent_runs(runner):
|
||
|
|
held = runner.acquire_lock()
|
||
|
|
try:
|
||
|
|
with pytest.raises(runner.InstallRunnerError, match="in progress"):
|
||
|
|
runner.acquire_lock()
|
||
|
|
finally:
|
||
|
|
held.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_install_happy_path(runner, monkeypatch):
|
||
|
|
import furtka.dockerops as dockerops
|
||
|
|
from furtka.paths import apps_dir
|
||
|
|
|
||
|
|
_write_installed_app(apps_dir(), "fileshare")
|
||
|
|
|
||
|
|
calls = []
|
||
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: calls.append(("pull", a)))
|
||
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: calls.append(("vol", name)))
|
||
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: calls.append(("up", a)))
|
||
|
|
|
||
|
|
runner.run_install("fileshare")
|
||
|
|
|
||
|
|
# Ordering: pull first, then volumes, then up.
|
||
|
|
assert [c[0] for c in calls] == ["pull", "vol", "up"]
|
||
|
|
# Exactly the namespaced volume name got created.
|
||
|
|
assert calls[1] == ("vol", "furtka_fileshare_files")
|
||
|
|
# Final state is "done" with the manifest version.
|
||
|
|
s = runner.read_state()
|
||
|
|
assert s["stage"] == "done"
|
||
|
|
assert s["app"] == "fileshare"
|
||
|
|
assert s["version"] == "0.1.0"
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_install_writes_error_on_pull_failure(runner, monkeypatch):
|
||
|
|
import furtka.dockerops as dockerops
|
||
|
|
from furtka.paths import apps_dir
|
||
|
|
|
||
|
|
_write_installed_app(apps_dir(), "fileshare")
|
||
|
|
|
||
|
|
def boom(*a, **k):
|
||
|
|
raise dockerops.DockerError("pull failed: registry unreachable")
|
||
|
|
|
||
|
|
monkeypatch.setattr(dockerops, "compose_pull", boom)
|
||
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
||
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
|
||
|
|
|
||
|
|
with pytest.raises(dockerops.DockerError):
|
||
|
|
runner.run_install("fileshare")
|
||
|
|
|
||
|
|
s = runner.read_state()
|
||
|
|
assert s["stage"] == "error"
|
||
|
|
assert s["app"] == "fileshare"
|
||
|
|
assert "registry unreachable" in s["error"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_install_writes_error_on_up_failure(runner, monkeypatch):
|
||
|
|
import furtka.dockerops as dockerops
|
||
|
|
from furtka.paths import apps_dir
|
||
|
|
|
||
|
|
_write_installed_app(apps_dir(), "fileshare")
|
||
|
|
|
||
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
|
||
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
||
|
|
|
||
|
|
def boom(*a, **k):
|
||
|
|
raise dockerops.DockerError("compose up: container refused to start")
|
||
|
|
|
||
|
|
monkeypatch.setattr(dockerops, "compose_up", boom)
|
||
|
|
|
||
|
|
with pytest.raises(dockerops.DockerError):
|
||
|
|
runner.run_install("fileshare")
|
||
|
|
|
||
|
|
s = runner.read_state()
|
||
|
|
assert s["stage"] == "error"
|
||
|
|
assert "refused to start" in s["error"]
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_install_releases_lock_after_done(runner, monkeypatch):
|
||
|
|
import furtka.dockerops as dockerops
|
||
|
|
from furtka.paths import apps_dir
|
||
|
|
|
||
|
|
_write_installed_app(apps_dir(), "fileshare")
|
||
|
|
monkeypatch.setattr(dockerops, "compose_pull", lambda *a, **k: None)
|
||
|
|
monkeypatch.setattr(dockerops, "ensure_volume", lambda name: None)
|
||
|
|
monkeypatch.setattr(dockerops, "compose_up", lambda *a, **k: None)
|
||
|
|
|
||
|
|
runner.run_install("fileshare")
|
||
|
|
|
||
|
|
# Lock released — a fresh acquire must succeed.
|
||
|
|
fh = runner.acquire_lock()
|
||
|
|
fh.close()
|
||
|
|
|
||
|
|
|
||
|
|
def test_run_install_releases_lock_after_error(runner, monkeypatch):
|
||
|
|
import furtka.dockerops as dockerops
|
||
|
|
from furtka.paths import apps_dir
|
||
|
|
|
||
|
|
_write_installed_app(apps_dir(), "fileshare")
|
||
|
|
monkeypatch.setattr(
|
||
|
|
dockerops, "compose_pull", lambda *a, **k: (_ for _ in ()).throw(dockerops.DockerError("x"))
|
||
|
|
)
|
||
|
|
|
||
|
|
with pytest.raises(dockerops.DockerError):
|
||
|
|
runner.run_install("fileshare")
|
||
|
|
|
||
|
|
fh = runner.acquire_lock()
|
||
|
|
fh.close()
|