"""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()