furtka/tests/test_cli.py

106 lines
3.3 KiB
Python
Raw Permalink Normal View History

import json
from furtka.cli import main
def _set_env(monkeypatch, tmp_path):
monkeypatch.setenv("FURTKA_APPS_DIR", str(tmp_path))
def test_app_list_empty(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
rc = main(["app", "list"])
assert rc == 0
assert "no apps installed" in capsys.readouterr().out
def test_app_list_json_empty(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
rc = main(["app", "list", "--json"])
assert rc == 0
assert json.loads(capsys.readouterr().out) == []
def test_app_list_json_with_one_app(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
app = tmp_path / "fileshare"
app.mkdir()
(app / "manifest.json").write_text(
json.dumps(
{
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB",
"description_long": "Long description here.",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
"open_url": "smb://{host}/files",
"settings": [
{
"name": "SMB_USER",
"label": "User",
"description": "SMB user",
"type": "text",
"default": "furtka",
"required": True,
}
],
}
)
)
rc = main(["app", "list", "--json"])
assert rc == 0
data = json.loads(capsys.readouterr().out)
assert len(data) == 1
assert data[0]["ok"] is True
m = data[0]["manifest"]
assert m["name"] == "fileshare"
assert m["description_long"] == "Long description here."
assert m["open_url"] == "smb://{host}/files"
assert len(m["settings"]) == 1
assert m["settings"][0]["name"] == "SMB_USER"
assert m["settings"][0]["required"] is True
assert m["settings"][0]["default"] == "furtka"
def test_reconcile_dry_run_empty(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
rc = main(["reconcile", "--dry-run"])
assert rc == 0
out = capsys.readouterr().out
feat(furtka): reconciler + install/remove — slice 2 Fills in the act-on-it half of the resource manager. Reconciler walks the scanner output and brings docker into the desired state: ensures each manifest-declared volume exists (idempotent), then runs docker compose up -d for the project. install/remove on the CLI work end-to-end against a real /var/lib/furtka/apps/ tree. - furtka.dockerops: thin subprocess wrapper. Volume + compose primitives that other modules call. `_run` raises DockerError with the actual stderr so failures are diagnosable. - furtka.reconciler: builds an ordered Action list (volumes then compose_up per app), executes unless dry-run. Broken manifests produce a "skip" action, the rest of the apps still get reconciled. - furtka.installer: copy-from-source with two non-obvious rules — user .env is preserved across upgrade installs, and a missing .env is bootstrapped from .env.example so compose has values to substitute on first install. Bundled-app lookup falls back to /opt/furtka/apps/<name>/ when the source arg isn't a path. - furtka.cli: app install/remove wired up. remove() ignores compose down failures so a botched compose doesn't trap users with an un-removable folder. - 15 new tests using monkeypatch'd dockerops so the suite still runs without docker installed. Covers reconcile dry-run, multi-volume apps, broken-manifest skip behavior, .env preservation, bundled-name resolution, and remove edge cases. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:02:00 +02:00
assert "0 actions" in out
feat(install): async background install with progress polling POST /api/apps/install now returns 202 Accepted after the synchronous pre-validation (resolve source, copy files, write .env, check for placeholder secrets, validate path-type settings). The docker-facing phases (compose pull → ensure volumes → compose up) are dispatched as a background systemd-run unit (furtka-install-<app>) that writes stage transitions to /var/lib/furtka/install-state.json. The UI polls GET /api/apps/install/status every 1.5s and re-labels the modal submit button — "Image wird heruntergeladen…" → "Speicherbereiche werden erstellt…" → "Container wird gestartet…" — instead of sitting dead on "Installing…" for 30+ seconds on large images like Jellyfin. Mirrors the exact shape of /api/catalog/sync/apply and /api/furtka/update/apply: same fcntl lock, same atomic state-file writes, same terminal-state poll loop ("done" | "error"). New CLI subcommand `furtka app install-bg <name>` is what systemd-run invokes; it's hidden from --help because regular CLI users still want the synchronous `furtka app install <name>`. Reinstall button on the app list polls too — after dispatch, its text reflects the background stage until terminal, matching the modal flow. Tests: - tests/test_install_runner.py (new, 9 cases): state roundtrip, lock contention, happy-path phase ordering, error writes on pull/up failure, lock release on both terminal outcomes. - tests/test_api.py: new no_systemd_run fixture stubs subprocess.run; existing install tests adapted to 202 response; new tests for 409 lock contention and the status endpoint. - tests/test_cli.py: install-bg dispatches correctly and returns 1 on failure with journald-friendly stderr. 256 tests pass, ruff check + format clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 15:50:49 +02:00
def test_app_install_bg_dispatches_to_runner(tmp_path, monkeypatch):
"""CLI `app install-bg <name>` must call install_runner.run_install(name).
This is the entry point the HTTP API fires via systemd-run; regression
here would leave the UI hanging at "pulling_image…" forever because
the background never transitions state.
"""
_set_env(monkeypatch, tmp_path)
from furtka import install_runner
called = []
monkeypatch.setattr(install_runner, "run_install", lambda name: called.append(name))
rc = main(["app", "install-bg", "fileshare"])
assert rc == 0
assert called == ["fileshare"]
def test_app_install_bg_returns_1_on_failure(tmp_path, monkeypatch, capsys):
_set_env(monkeypatch, tmp_path)
from furtka import install_runner
def boom(name):
raise RuntimeError("compose pull failed")
monkeypatch.setattr(install_runner, "run_install", boom)
rc = main(["app", "install-bg", "fileshare"])
assert rc == 1
err = capsys.readouterr().err
assert "install-bg failed" in err
assert "compose pull failed" in err