furtka/tests/test_scanner.py
Daniel Maksymilian Syrnicki cfc4c0b9c1 feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.

- furtka.manifest: dataclass + load_manifest with required-field +
  type validation. volume_name() injects the furtka_<app>_<vol>
  namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
  not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
  `reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
  scanner edge cases (missing root, broken manifest, sort order), and
  the CLI subcommands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00

69 lines
1.8 KiB
Python

import json
from furtka.scanner import scan
VALID_MANIFEST = {
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share",
"volumes": ["files"],
"ports": [445],
"icon": "icon.svg",
}
def _make_app(root, name, manifest=None):
app = root / name
app.mkdir(parents=True)
if manifest is not None:
(app / "manifest.json").write_text(json.dumps(manifest))
return app
def test_scan_missing_root(tmp_path):
assert scan(tmp_path / "does-not-exist") == []
def test_scan_empty_root(tmp_path):
assert scan(tmp_path) == []
def test_scan_valid_app(tmp_path):
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
results = scan(tmp_path)
assert len(results) == 1
assert results[0].ok
assert results[0].manifest.name == "fileshare"
def test_scan_skips_files(tmp_path):
_make_app(tmp_path, "fileshare", VALID_MANIFEST)
(tmp_path / "stray.txt").write_text("ignore me")
results = scan(tmp_path)
assert len(results) == 1
def test_scan_missing_manifest(tmp_path):
_make_app(tmp_path, "broken")
results = scan(tmp_path)
assert len(results) == 1
assert not results[0].ok
assert "manifest.json missing" in results[0].error
def test_scan_invalid_manifest(tmp_path):
bad = dict(VALID_MANIFEST)
del bad["volumes"]
_make_app(tmp_path, "fileshare", bad)
results = scan(tmp_path)
assert len(results) == 1
assert not results[0].ok
assert "volumes" in results[0].error
def test_scan_sorted_by_name(tmp_path):
_make_app(tmp_path, "z-app", dict(VALID_MANIFEST, name="z-app"))
_make_app(tmp_path, "a-app", dict(VALID_MANIFEST, name="a-app"))
results = scan(tmp_path)
assert [r.path.name for r in results] == ["a-app", "z-app"]