furtka/tests/test_deps.py

184 lines
7 KiB
Python
Raw Normal View History

import json
import pytest
from furtka import deps
BASE_MANIFEST = {
"display_name": "X",
"version": "0.1.0",
"description": "x",
"volumes": [],
"ports": [],
"icon": "icon.svg",
}
@pytest.fixture
def apps_root(tmp_path, monkeypatch):
"""Three roots: installed, catalog, bundled. Each set up empty by default."""
installed = tmp_path / "installed"
catalog = tmp_path / "catalog" / "apps"
bundled = tmp_path / "bundled"
for p in (installed, catalog, bundled):
p.mkdir(parents=True)
monkeypatch.setenv("FURTKA_APPS_DIR", str(installed))
monkeypatch.setenv("FURTKA_CATALOG_DIR", str(tmp_path / "catalog"))
monkeypatch.setenv("FURTKA_BUNDLED_APPS_DIR", str(bundled))
return {"installed": installed, "catalog": catalog, "bundled": bundled}
def _write_manifest(root, name, **overrides):
app = root / name
app.mkdir(parents=True, exist_ok=True)
payload = dict(BASE_MANIFEST, name=name, **overrides)
(app / "manifest.json").write_text(json.dumps(payload))
return app
def test_plan_install_no_deps(apps_root):
_write_manifest(apps_root["catalog"], "alone")
plan = deps.plan_install("alone")
assert plan.target == "alone"
assert plan.install_order == ("alone",)
assert plan.to_install == ("alone",)
assert plan.already_installed == frozenset()
def test_plan_install_linear_chain(apps_root):
# A requires B, B requires C — all in catalog, none installed yet.
_write_manifest(apps_root["catalog"], "c")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("c", "b", "a")
assert plan.to_install == ("c", "b", "a")
def test_plan_install_diamond(apps_root):
# A requires B and C; B requires D; C requires D. D must appear once,
# before B and C, which come before A.
_write_manifest(apps_root["catalog"], "d")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "d"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}, {"app": "c"}])
plan = deps.plan_install("a")
order = plan.install_order
# D first, A last, B and C in between (deterministically alphabetical).
assert order[0] == "d"
assert order[-1] == "a"
assert set(order[1:-1]) == {"b", "c"}
assert order.count("d") == 1
def test_plan_install_already_installed_provider(apps_root):
_write_manifest(apps_root["installed"], "b") # provider already installed
_write_manifest(apps_root["catalog"], "b")
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
assert plan.install_order == ("b", "a")
assert plan.to_install == ("a",)
assert plan.already_installed == frozenset({"b"})
def test_plan_install_cycle_two_node(apps_root):
# Manifest validator already rejects self-reference at load time, but
# mutual references (A -> B -> A) only show up at plan time.
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="circular"):
deps.plan_install("a")
def test_plan_install_cycle_three_node(apps_root):
_write_manifest(apps_root["catalog"], "c", requires=[{"app": "a"}])
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "c"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
with pytest.raises(deps.DependencyError, match="a -> b -> c -> a"):
deps.plan_install("a")
def test_plan_install_missing_provider(apps_root):
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "ghost"}])
with pytest.raises(deps.DependencyError, match="ghost"):
deps.plan_install("a")
def test_plan_install_prefers_installed_over_catalog(apps_root):
# If a provider exists in both installed and catalog, we resolve via
# installed (so we read the actual on-disk manifest the user has).
_write_manifest(apps_root["installed"], "b")
_write_manifest(apps_root["catalog"], "b", requires=[{"app": "extra"}])
_write_manifest(apps_root["catalog"], "a", requires=[{"app": "b"}])
plan = deps.plan_install("a")
# The installed manifest has no requires, so "extra" is NOT pulled in.
assert plan.install_order == ("b", "a")
def test_dependents_of_empty(apps_root):
assert deps.dependents_of("anything") == ()
def test_dependents_of_finds_consumers(apps_root):
_write_manifest(apps_root["installed"], "x")
_write_manifest(apps_root["installed"], "a", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "b", requires=[{"app": "x"}])
_write_manifest(apps_root["installed"], "unrelated")
assert deps.dependents_of("x") == ("a", "b")
assert deps.dependents_of("unrelated") == ()
def test_installed_topo_order_preserves_alpha_when_independent(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "alpha")
_write_manifest(apps_root["installed"], "bravo")
_write_manifest(apps_root["installed"], "charlie")
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
assert [r.manifest.name for r in ordered] == ["alpha", "bravo", "charlie"]
def test_installed_topo_order_puts_providers_first(apps_root):
from furtka.scanner import scan
# Alphabetically z2m comes before mqtt? No — but let's force the
# dependency to win. consumer=alpha requires=provider=zulu, so naive
# alpha order would put alpha first. Topo must flip them.
_write_manifest(apps_root["installed"], "zulu")
_write_manifest(apps_root["installed"], "alpha", requires=[{"app": "zulu"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
assert names == ["zulu", "alpha"]
def test_installed_topo_order_missing_provider_tails_app(apps_root):
from furtka.scanner import scan
_write_manifest(apps_root["installed"], "good")
_write_manifest(apps_root["installed"], "needy", requires=[{"app": "ghost"}])
ordered = deps.installed_topo_order(scan(apps_root["installed"]))
names = [r.manifest.name for r in ordered]
# `good` first (no deps), `needy` last (unresolved).
assert names == ["good", "needy"]
def test_provider_exec_service_picks_first_service(apps_root, monkeypatch):
from furtka import dockerops
monkeypatch.setattr(
dockerops,
"compose_image_tags",
lambda app_dir, project: {"server": "img:1", "worker": "img:2"},
)
assert deps.provider_exec_service(apps_root["installed"] / "x", "x") == "server"
def test_provider_exec_service_falls_back_to_project_on_docker_error(apps_root, monkeypatch):
from furtka import dockerops
def boom(app_dir, project):
raise dockerops.DockerError("docker not running")
monkeypatch.setattr(dockerops, "compose_image_tags", boom)
assert deps.provider_exec_service(apps_root["installed"] / "x", "myproj") == "myproj"