"""Integration tests for OpsCatalog (T14-T16 from BRIDGE-WP-0002).""" import json import textwrap from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner from bridge.catalog.loader import load_catalog from bridge.catalog.resolver import resolve from bridge.catalog.validator import validate_catalog from bridge.cli import app runner = CliRunner() @pytest.fixture def catalog_dir(tmp_path): root = tmp_path / "opscatalog" domain_dir = root / "domains" / "coulombcore" (domain_dir / "targets").mkdir(parents=True) (domain_dir / "bridges").mkdir(parents=True) (domain_dir / "docs").mkdir(parents=True) actors_dir = root / "actors" actors_dir.mkdir(parents=True) (domain_dir / "domain.yaml").write_text(textwrap.dedent("""\ type: domain id: coulombcore name: CoulombCore Infrastructure description: Core infra environment: production """)) (domain_dir / "targets" / "state-hub.yaml").write_text(textwrap.dedent("""\ type: target id: state-hub domain: coulombcore kind: service description: State coordination service reachable_via: - state-hub-coulombcore """)) (domain_dir / "bridges" / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\ type: bridge id: state-hub-coulombcore domain: coulombcore target: state-hub description: Ops bridge for state hub access_method: ssh-reverse host: coulombcore.local remote_port: 18000 local_port: 8000 ssh_user: ubuntu ssh_key: ~/.ssh/id_ops actor: agent.claude-coulombcore reconnect: max_attempts: 0 backoff_initial: 5 backoff_max: 60 """)) (actors_dir / "agents.yaml").write_text(textwrap.dedent("""\ type: actor id: agent.claude-coulombcore class: automation description: Claude Code agent on CoulombCore """)) (domain_dir / "docs" / "overview.md").write_text( "# CoulombCore Overview\nCore infrastructure notes." ) return root @pytest.fixture def config_with_catalog(tmp_path, catalog_dir): f = tmp_path / "tunnels.yaml" f.write_text(textwrap.dedent(f"""\ catalog_path: {catalog_dir} tunnels: {{}} actors: {{}} """)) return f @pytest.fixture def env(config_with_catalog, tmp_path): return { "BRIDGE_CONFIG": str(config_with_catalog), "BRIDGE_STATE_DIR": str(tmp_path / "state"), } class TestT14CatalogLoadAndResolve: def test_catalog_loads_all_types(self, catalog_dir): cat = load_catalog(catalog_dir) assert "coulombcore" in cat.domains assert "state-hub" in cat.targets assert "state-hub-coulombcore" in cat.bridges assert "agent.claude-coulombcore" in cat.actors def test_resolve_from_catalog(self, catalog_dir): cat = load_catalog(catalog_dir) tc = resolve("state-hub-coulombcore", catalog=cat, inline_tunnels={}) assert tc.name == "state-hub-coulombcore" assert tc.host == "coulombcore.local" assert tc.remote_port == 18000 def test_bridge_up_with_catalog_bridge(self, env): with patch("bridge.cli.TunnelManager") as mock_mgr_cls: mock_mgr = MagicMock() mock_mgr.is_running.return_value = False mock_mgr_cls.return_value = mock_mgr result = runner.invoke(app, ["up", "state-hub-coulombcore"], env=env) assert result.exit_code == 0 mock_mgr.start.assert_called_once() # Verify TunnelManager was constructed with correct config call_args = mock_mgr_cls.call_args tcfg = call_args[0][0] assert tcfg.host == "coulombcore.local" assert tcfg.remote_port == 18000 class TestT15BridgeTargetsOutput: def test_targets_table(self, env): result = runner.invoke(app, ["targets"], env=env) assert result.exit_code == 0 assert "state-hub" in result.output assert "coulombcore" in result.output assert "service" in result.output def test_targets_json_structure(self, env): result = runner.invoke(app, ["targets", "--json"], env=env) assert result.exit_code == 0 data = json.loads(result.output) assert len(data) == 1 t = data[0] assert t["target"] == "state-hub" assert t["domain"] == "coulombcore" assert t["kind"] == "service" assert "state-hub-coulombcore" in t["bridges"] def test_targets_show_includes_docs(self, env): result = runner.invoke(app, ["targets", "show", "state-hub"], env=env) assert result.exit_code == 0 assert "state-hub" in result.output assert "coulombcore" in result.output class TestT16CatalogValidate: def test_validate_clean_catalog_exit_0(self, env): result = runner.invoke(app, ["catalog", "validate"], env=env) assert result.exit_code == 0 assert "ok" in result.output.lower() or "0" in result.output def test_validate_dangling_reference_exit_1(self, tmp_path): root = tmp_path / "bad" domain_dir = root / "domains" / "d" (domain_dir / "targets").mkdir(parents=True) (domain_dir / "bridges").mkdir(parents=True) (root / "actors").mkdir(parents=True) (domain_dir / "domain.yaml").write_text("type: domain\nid: d\nname: D\n") (domain_dir / "targets" / "t.yaml").write_text( "type: target\nid: t\ndomain: d\nkind: service\n" "reachable_via:\n - nonexistent-bridge\n" ) (domain_dir / "bridges" / "b.yaml").write_text( "type: bridge\nid: b\ndomain: d\ntarget: t\n" "host: h\nremote_port: 1\nlocal_port: 2\n" "ssh_user: u\nssh_key: k\nactor: missing-actor\n" ) f = tmp_path / "tunnels.yaml" f.write_text(f"catalog_path: {root}\ntunnels: {{}}\nactors: {{}}\n") result = runner.invoke(app, ["catalog", "validate"], env={"BRIDGE_CONFIG": str(f)}) assert result.exit_code == 1 assert "nonexistent-bridge" in result.output or "missing-actor" in result.output def test_catalog_list_shows_counts(self, env): result = runner.invoke(app, ["catalog", "list"], env=env) assert result.exit_code == 0 assert "coulombcore" in result.output def test_catalog_show_bridge(self, env): result = runner.invoke(app, ["catalog", "show", "state-hub-coulombcore"], env=env) assert result.exit_code == 0 assert "coulombcore.local" in result.output assert "18000" in result.output def test_validate_using_validator_directly(self, catalog_dir): cat = load_catalog(catalog_dir) errors = validate_catalog(cat) assert errors == []