Files
railiance-fabric/tests/test_scan_manifest.py

395 lines
13 KiB
Python

from __future__ import annotations
import json
import threading
from http.server import ThreadingHTTPServer
from pathlib import Path
from railiance_fabric.cli import main as cli_main
from railiance_fabric.registry import RegistryStore
from railiance_fabric.schema_validation import draft202012_validator
from railiance_fabric.server import RegistryHandler
def test_registry_scan_manifest_dry_run_keeps_repo_failures_isolated(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(
tmp_path,
[
{"slug": "fixture-repo", "name": "Fixture Repo", "path": str(repo)},
{"slug": "missing-repo", "name": "Missing Repo", "path": str(tmp_path / "missing-repo")},
],
)
output_dir = tmp_path / "snapshots"
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--dry-run",
"--output-dir",
str(output_dir),
"--json",
]
) == 0
summary = json.loads(capsys.readouterr().out)
assert summary["counts"]["total"] == 2
assert summary["counts"]["scanned"] == 1
assert summary["counts"]["errors"] == 1
assert summary["counts"]["llm_skipped"] == 2
assert summary["repositories"][0]["status"] == "scanned"
assert summary["repositories"][1]["status"] == "error"
assert (output_dir / "fixture-repo-deterministic.discovery.json").is_file()
def test_registry_scan_manifest_writes_default_cache_and_report(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(tmp_path, [{"slug": "fixture-repo", "name": "Fixture Repo", "path": str(repo)}])
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--dry-run",
"--json",
]
) == 0
summary = json.loads(capsys.readouterr().out)
cache_dir = tmp_path / ".fabric-discovery"
snapshot_path = cache_dir / "snapshots" / "fixture-repo-deterministic.discovery.json"
assert summary["cache"]["cache_dir"] == str(cache_dir)
assert summary["repositories"][0]["change_state"] == "baseline"
assert summary["repositories"][0]["previous"]["source"] == "dir"
assert summary["repositories"][0]["previous"]["found"] is False
assert snapshot_path.is_file()
assert Path(summary["report_path"]).is_file()
report = json.loads(Path(summary["report_path"]).read_text(encoding="utf-8"))
assert report["kind"] == "FabricDiscoveryRescanReport"
assert report["counts"]["baseline"] == 1
draft202012_validator(Path("schemas") / "rescan-run-report.schema.yaml").validate(report)
assert not (cache_dir / "rescan.lock").exists()
def test_registry_scan_manifest_operational_exit_codes_and_lock(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(tmp_path, [{"slug": "fixture-repo", "name": "Fixture Repo", "path": str(repo)}])
lock_file = tmp_path / "busy.lock"
lock_file.write_text("busy\n", encoding="utf-8")
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--dry-run",
"--lock-file",
str(lock_file),
"--exit-code-mode",
"operational",
]
) == 5
assert "rescan lock already exists" in capsys.readouterr().err
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--dry-run",
"--exit-code-mode",
"operational",
"--json",
]
) == 2
summary = json.loads(capsys.readouterr().out)
assert summary["counts"]["baseline"] == 1
def test_registry_scan_manifest_reconciles_from_default_cache(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(tmp_path, [{"slug": "fixture-repo", "name": "Fixture Repo", "path": str(repo)}])
assert cli_main(["registry", "scan-manifest", str(manifest), "--dry-run", "--json"]) == 0
capsys.readouterr()
_write_pyproject(repo, ["PyYAML>=6.0"])
assert cli_main(["registry", "scan-manifest", str(manifest), "--dry-run", "--json"]) == 0
summary = json.loads(capsys.readouterr().out)
repo_result = summary["repositories"][0]
assert repo_result["previous"]["source"] == "dir"
assert repo_result["previous"]["found"] is True
assert repo_result["change_state"] == "changed"
assert repo_result["diff_counts"]["added"] > 0
def test_registry_scan_manifest_ingests_and_accepts_snapshots(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(
tmp_path,
[{"slug": "fixture-repo", "name": "Fixture Repo", "domain": "testing", "path": str(repo)}],
)
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_port}"
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--repo-slug",
"fixture-repo",
"--ingest",
"--accept",
"--json",
]
) == 0
summary = json.loads(capsys.readouterr().out)
assert summary["counts"]["total"] == 1
assert summary["counts"]["scanned"] == 1
assert summary["counts"]["ingested"] == 1
assert summary["counts"]["accepted"] == 1
assert summary["counts"]["errors"] == 0
assert summary["repositories"][0]["discovery_snapshot_id"] == 1
assert store.list_discovery_snapshots("fixture-repo")[0]["id"] == 1
assert store.latest_snapshots()[0]["commit"].startswith("discovery:")
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_registry_scan_manifest_reconciles_from_registry_and_skips_unchanged_ingest(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(
tmp_path,
[{"slug": "fixture-repo", "name": "Fixture Repo", "domain": "testing", "path": str(repo)}],
)
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_port}"
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--ingest",
"--json",
]
) == 0
first = json.loads(capsys.readouterr().out)
assert first["repositories"][0]["change_state"] == "baseline"
assert first["counts"]["ingested"] == 1
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--previous-from-registry",
"--ingest",
"--json",
]
) == 0
second = json.loads(capsys.readouterr().out)
repo_result = second["repositories"][0]
assert repo_result["previous"]["source"] == "registry"
assert repo_result["previous"]["found"] is True
assert repo_result["change_state"] == "unchanged"
assert repo_result["registry_action"] == "skipped_unchanged"
assert second["counts"]["ingested"] == 0
assert len(store.list_discovery_snapshots("fixture-repo")) == 1
_write_pyproject(repo, ["PyYAML>=6.0"])
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--previous-from-registry",
"--dry-run",
"--json",
]
) == 0
changed = json.loads(capsys.readouterr().out)
assert changed["repositories"][0]["change_state"] == "changed"
assert changed["repositories"][0]["previous"]["discovery_snapshot_id"] == 1
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def test_registry_scan_manifest_registry_previous_unavailable_is_per_repo_error(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
manifest = _manifest(tmp_path, [{"slug": "fixture-repo", "name": "Fixture Repo", "path": str(repo)}])
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
"http://127.0.0.1:1",
"--previous-from-registry",
"--dry-run",
"--json",
]
) == 0
summary = json.loads(capsys.readouterr().out)
assert summary["counts"]["errors"] == 1
assert summary["repositories"][0]["status"] == "error"
assert "cannot reach registry" in summary["repositories"][0]["error"]
def test_registry_scan_manifest_safe_accept_blocks_tombstone_projection(tmp_path: Path, capsys) -> None:
repo = _minimal_repo(tmp_path, "fixture-repo")
_write_pyproject(repo, ["PyYAML>=6.0", "requests>=2.31"])
manifest = _manifest(
tmp_path,
[{"slug": "fixture-repo", "name": "Fixture Repo", "domain": "testing", "path": str(repo)}],
)
store = RegistryStore(tmp_path / "registry.sqlite3")
store.init_schema()
class Handler(RegistryHandler):
pass
Handler.store = store
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try:
base_url = f"http://127.0.0.1:{server.server_port}"
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--ingest",
"--json",
]
) == 0
capsys.readouterr()
_write_pyproject(repo, ["PyYAML>=6.0"])
assert cli_main(
[
"registry",
"scan-manifest",
str(manifest),
"--registry-url",
base_url,
"--previous-from-registry",
"--ingest",
"--accept",
"--json",
]
) == 0
summary = json.loads(capsys.readouterr().out)
repo_result = summary["repositories"][0]
assert repo_result["status"] == "review_required"
assert repo_result["registry_action"] == "accept_blocked"
assert repo_result["accepted"] is False
assert repo_result["tombstone_count"] > 0
assert summary["counts"]["review_required"] == 1
assert len(store.list_discovery_snapshots("fixture-repo")) == 2
assert store.latest_snapshots() == []
health = store.status()["latest_discovery_snapshots"][0]
assert health["health"] == "needs_review"
assert health["review_required"] is True
assert health["tombstone_count"] > 0
assert cli_main(
[
"registry",
"rescan-status",
"--registry-url",
base_url,
"--review-only",
"--json",
]
) == 0
rescan_status = json.loads(capsys.readouterr().out)
assert rescan_status["kind"] == "FabricDiscoveryRescanStatus"
assert rescan_status["counts"]["review_required"] == 1
assert rescan_status["repositories"][0]["repo_slug"] == "fixture-repo"
finally:
server.shutdown()
server.server_close()
thread.join(timeout=5)
def _minimal_repo(tmp_path: Path, slug: str) -> Path:
repo = tmp_path / slug
repo.mkdir()
(repo / "README.md").write_text(f"# {slug}\n", encoding="utf-8")
return repo
def _manifest(tmp_path: Path, repositories: list[dict[str, object]]) -> Path:
manifest = tmp_path / "local-repos.yaml"
manifest.write_text(
json.dumps(
{
"apiVersion": "railiance.fabric/v1alpha1",
"kind": "RegistryOnboardingManifest",
"registry_url": "http://127.0.0.1:8765",
"repositories": repositories,
}
),
encoding="utf-8",
)
return manifest
def _write_pyproject(repo: Path, dependencies: list[str]) -> None:
dependency_lines = "\n".join(f' "{dependency}",' for dependency in dependencies)
(repo / "pyproject.toml").write_text(
f"""
[project]
name = "fixture-service"
version = "0.1.0"
dependencies = [
{dependency_lines}
]
""".lstrip(),
encoding="utf-8",
)