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_disambiguates_normalized_snapshot_names(tmp_path: Path, capsys) -> None: repo_a = _minimal_repo(tmp_path, "vergabe-teilnahme") repo_b = _minimal_repo(tmp_path, "vergabe_teilnahme") manifest = _manifest( tmp_path, [ {"slug": "vergabe-teilnahme", "name": "Vergabe Teilnahme", "path": str(repo_a)}, {"slug": "vergabe_teilnahme", "name": "Vergabe Teilnahme Alt", "path": str(repo_b)}, ], ) 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) output_paths = [item["output_path"] for item in summary["repositories"]] assert len(output_paths) == 2 assert len(set(output_paths)) == 2 assert (output_dir / "vergabe-teilnahme-deterministic.discovery.json").is_file() disambiguated = [ path for path in output_dir.glob("vergabe-teilnahme-*-deterministic.discovery.json") if path.name != "vergabe-teilnahme-deterministic.discovery.json" ] assert len(disambiguated) == 1 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", )