"""Tests for collection.py - Collection definitions and management.""" import tempfile from pathlib import Path from unittest.mock import patch, MagicMock import pytest import yaml from cmdforge.collection import ( Collection, list_collections, get_collection, COLLECTIONS_DIR, classify_tool_reference, resolve_tool_references, ToolResolutionResult, gather_local_unpublished_deps, DepCheckResult ) @pytest.fixture def temp_collections_dir(tmp_path): """Create a temporary collections directory.""" with patch('cmdforge.collection.COLLECTIONS_DIR', tmp_path): yield tmp_path class TestCollection: """Tests for Collection dataclass.""" def test_create_basic(self): coll = Collection(name="my-tools", display_name="My Tools") assert coll.name == "my-tools" assert coll.display_name == "My Tools" assert coll.description == "" assert coll.tools == [] assert coll.pinned == {} assert coll.tags == [] assert coll.published is False def test_create_full(self): coll = Collection( name="dev-tools", display_name="Developer Tools", description="Essential tools for developers", maintainer="rob", tools=["summarize", "official/code-review"], pinned={"official/code-review": "^1.0.0"}, tags=["dev", "productivity"], published=True, registry_name="dev-tools" ) assert coll.name == "dev-tools" assert coll.maintainer == "rob" assert len(coll.tools) == 2 assert coll.pinned["official/code-review"] == "^1.0.0" assert coll.published is True def test_to_dict(self): coll = Collection( name="test-coll", display_name="Test Collection", description="Test", tools=["tool1"], tags=["test"] ) d = coll.to_dict() assert d["name"] == "test-coll" assert d["display_name"] == "Test Collection" assert d["tools"] == ["tool1"] assert d["tags"] == ["test"] assert d["published"] is False def test_save_and_load(self, temp_collections_dir): coll = Collection( name="my-coll", display_name="My Collection", description="A test collection", tools=["tool1", "owner/tool2"], pinned={"owner/tool2": "1.0.0"}, tags=["test"] ) coll.save() # Verify file exists path = temp_collections_dir / "my-coll.yaml" assert path.exists() # Load and verify loaded = Collection.load("my-coll") assert loaded is not None assert loaded.name == "my-coll" assert loaded.display_name == "My Collection" assert loaded.tools == ["tool1", "owner/tool2"] assert loaded.pinned == {"owner/tool2": "1.0.0"} def test_load_nonexistent(self, temp_collections_dir): loaded = Collection.load("nonexistent") assert loaded is None def test_load_empty_yaml(self, temp_collections_dir): # Create empty YAML file path = temp_collections_dir / "empty.yaml" path.write_text("") loaded = Collection.load("empty") assert loaded is not None assert loaded.name == "empty" def test_delete(self, temp_collections_dir): coll = Collection(name="to-delete", display_name="To Delete") coll.save() path = temp_collections_dir / "to-delete.yaml" assert path.exists() coll.delete() assert not path.exists() def test_get_registry_url(self): with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"): coll = Collection(name="my-coll", display_name="My Coll") url = coll.get_registry_url() assert url == "https://cmdforge.brrd.tech/collections/my-coll" def test_get_registry_url_custom_base(self): with patch('cmdforge.collection.get_registry_url', return_value="https://custom.example.com/api/v1"): coll = Collection(name="my-coll", display_name="My Coll") url = coll.get_registry_url() assert url == "https://custom.example.com/collections/my-coll" def test_get_registry_url_no_api_suffix(self): with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech"): coll = Collection(name="my-coll", display_name="My Coll") url = coll.get_registry_url() assert url == "https://cmdforge.brrd.tech/collections/my-coll" def test_get_registry_url_uses_registry_name(self): with patch('cmdforge.collection.get_registry_url', return_value="https://cmdforge.brrd.tech/api/v1"): coll = Collection(name="local-name", display_name="My Coll", registry_name="published-name") url = coll.get_registry_url() assert url == "https://cmdforge.brrd.tech/collections/published-name" class TestListCollections: """Tests for list_collections function.""" def test_empty_dir(self, temp_collections_dir): result = list_collections() assert result == [] def test_with_collections(self, temp_collections_dir): # Create some collections Collection(name="alpha", display_name="Alpha").save() Collection(name="beta", display_name="Beta").save() Collection(name="gamma", display_name="Gamma").save() result = list_collections() assert result == ["alpha", "beta", "gamma"] # Sorted def test_ignores_non_yaml_files(self, temp_collections_dir): Collection(name="valid", display_name="Valid").save() (temp_collections_dir / "not-yaml.txt").write_text("ignored") result = list_collections() assert result == ["valid"] class TestGetCollection: """Tests for get_collection function.""" def test_existing(self, temp_collections_dir): Collection(name="test", display_name="Test", description="Desc").save() coll = get_collection("test") assert coll is not None assert coll.name == "test" assert coll.description == "Desc" def test_nonexistent(self, temp_collections_dir): coll = get_collection("nonexistent") assert coll is None class TestClassifyToolReference: """Tests for classify_tool_reference function.""" def test_local_tool(self): owner, name, is_local = classify_tool_reference("summarize") assert owner == "" assert name == "summarize" assert is_local is True def test_registry_tool(self): owner, name, is_local = classify_tool_reference("official/summarize") assert owner == "official" assert name == "summarize" assert is_local is False def test_registry_tool_nested_name(self): owner, name, is_local = classify_tool_reference("user/my-tool") assert owner == "user" assert name == "my-tool" assert is_local is False class TestResolveToolReferences: """Tests for resolve_tool_references function.""" @pytest.fixture def mock_client(self): client = MagicMock() client.get_my_tool_status.side_effect = lambda name: {"status": "approved"} client.has_approved_public_tool.return_value = True return client def test_all_registry_tools(self, mock_client): tools = ["official/tool1", "user/tool2"] pinned = {"official/tool1": "^1.0.0"} result = resolve_tool_references(tools, pinned, "myuser", mock_client) assert result.registry_refs == ["official/tool1", "user/tool2"] assert result.transformed_pinned == {"official/tool1": "^1.0.0"} assert result.local_unpublished == [] def test_local_tool_not_published(self, mock_client): from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'): tools = ["my-local-tool"] result = resolve_tool_references(tools, {}, "myuser", mock_client) assert "myuser/my-local-tool" in result.registry_refs assert "my-local-tool" in result.local_unpublished def test_local_tool_published_approved(self, mock_client): mock_client.get_my_tool_status.return_value = {"status": "approved"} with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'): tools = ["my-tool"] result = resolve_tool_references(tools, {}, "myuser", mock_client) assert "myuser/my-tool" in result.registry_refs assert result.local_unpublished == [] assert ("my-tool", "approved", True) in result.local_published def test_transforms_pinned_for_local_tools(self, mock_client): mock_client.get_my_tool_status.return_value = {"status": "approved"} with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='public'): tools = ["my-tool"] pinned = {"my-tool": "1.0.0"} result = resolve_tool_references(tools, pinned, "myuser", mock_client) assert "myuser/my-tool" in result.transformed_pinned assert result.transformed_pinned["myuser/my-tool"] == "1.0.0" def test_visibility_issues_detected(self, mock_client): with patch('cmdforge.collection._get_tool_visibility_from_yaml', return_value='private'): tools = ["private-tool"] result = resolve_tool_references(tools, {}, "myuser", mock_client) assert ("private-tool", "private") in result.visibility_issues def test_registry_tool_not_approved(self, mock_client): mock_client.has_approved_public_tool.return_value = False tools = ["official/unapproved"] result = resolve_tool_references(tools, {}, "myuser", mock_client) assert ("official/unapproved", "no approved public version") in result.registry_tool_issues class TestToolResolutionResult: """Tests for ToolResolutionResult dataclass.""" def test_empty_result(self): result = ToolResolutionResult( registry_refs=[], transformed_pinned={}, local_unpublished=[], local_published=[], visibility_issues=[], registry_tool_issues=[] ) assert len(result.registry_refs) == 0 assert len(result.local_unpublished) == 0 def test_with_data(self): result = ToolResolutionResult( registry_refs=["user/tool1", "user/tool2"], transformed_pinned={"user/tool1": "^1.0"}, local_unpublished=["tool3"], local_published=[("tool1", "approved", True)], visibility_issues=[("tool4", "private")], registry_tool_issues=[("other/tool", "not found")] ) assert len(result.registry_refs) == 2 assert len(result.local_unpublished) == 1 assert len(result.visibility_issues) == 1 class TestDepCheckResult: """Tests for DepCheckResult dataclass.""" def test_empty_result(self): result = DepCheckResult( unpublished=[], publish_order=[], cycles=[], skipped=[] ) assert len(result.unpublished) == 0 assert len(result.cycles) == 0 def test_with_data(self): result = DepCheckResult( unpublished=["tool-a", "tool-b"], publish_order=["tool-b", "tool-a", "main"], cycles=[["x", "y", "x"]], skipped=["tool-z"] ) assert result.unpublished == ["tool-a", "tool-b"] assert result.publish_order == ["tool-b", "tool-a", "main"] assert result.cycles == [["x", "y", "x"]] assert result.skipped == ["tool-z"] class TestGatherLocalUnpublishedDeps: """Tests for transitive dependency checking.""" @pytest.fixture def mock_client(self): """Create a mock registry client.""" client = MagicMock() client.get_me.return_value = {"slug": "testuser"} return client @pytest.fixture def temp_tools_dir(self, tmp_path): """Create a temporary tools directory.""" with patch('cmdforge.tool.TOOLS_DIR', tmp_path): yield tmp_path def _create_tool(self, tools_dir, name, deps=None, tool_steps=None): """Helper to create a tool config in the temp dir.""" tool_dir = tools_dir / name tool_dir.mkdir(parents=True, exist_ok=True) config = { "name": name, "description": f"Test tool {name}", "steps": [], "output": "{input}" } if deps: config["dependencies"] = deps if tool_steps: for step in tool_steps: config["steps"].append({ "type": "tool", "tool": step, "output_var": "result" }) (tool_dir / "config.yaml").write_text(yaml.dump(config)) def test_no_dependencies_approved(self, mock_client, temp_tools_dir): """Approved tool with no deps should return empty unpublished list.""" mock_client.get_my_tool_status.return_value = {"status": "approved"} self._create_tool(temp_tools_dir, "standalone") result = gather_local_unpublished_deps(["standalone"], mock_client, "testuser") assert result.unpublished == [] assert result.cycles == [] def test_no_dependencies_not_published(self, mock_client, temp_tools_dir): """Unpublished tool with no deps should be in unpublished list.""" from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) self._create_tool(temp_tools_dir, "standalone") result = gather_local_unpublished_deps(["standalone"], mock_client, "testuser") # The original tool itself is now checked and included if not approved assert "standalone" in result.unpublished assert result.cycles == [] def test_gathers_explicit_deps(self, mock_client, temp_tools_dir): """Should find explicit dependencies.""" from cmdforge.registry_client import RegistryError # dep-tool is unpublished mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) self._create_tool(temp_tools_dir, "dep-tool") self._create_tool(temp_tools_dir, "main-tool", deps=["dep-tool"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "dep-tool" in result.unpublished # dep-tool should come before main-tool in publish order assert result.publish_order.index("dep-tool") < result.publish_order.index("main-tool") def test_gathers_tool_step_deps(self, mock_client, temp_tools_dir): """Should find implicit dependencies from ToolStep.""" from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) self._create_tool(temp_tools_dir, "called-tool") self._create_tool(temp_tools_dir, "caller-tool", tool_steps=["called-tool"]) result = gather_local_unpublished_deps(["caller-tool"], mock_client, "testuser") assert "called-tool" in result.unpublished def test_gathers_transitive_deps(self, mock_client, temp_tools_dir): """Should find dependencies of dependencies.""" from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) # a -> b -> c self._create_tool(temp_tools_dir, "tool-c") self._create_tool(temp_tools_dir, "tool-b", deps=["tool-c"]) self._create_tool(temp_tools_dir, "tool-a", deps=["tool-b"]) result = gather_local_unpublished_deps(["tool-a"], mock_client, "testuser") assert "tool-b" in result.unpublished assert "tool-c" in result.unpublished # Order: c before b before a assert result.publish_order.index("tool-c") < result.publish_order.index("tool-b") assert result.publish_order.index("tool-b") < result.publish_order.index("tool-a") def test_skips_qualified_refs(self, mock_client, temp_tools_dir): """Should skip owner/name registry references but check main tool.""" from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) # Tool depends on a registry tool self._create_tool(temp_tools_dir, "main-tool", deps=["official/summarize"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") # Registry refs should NOT be in unpublished assert "official/summarize" not in result.unpublished # But the main tool itself is checked and included since not found assert "main-tool" in result.unpublished def test_skips_qualified_refs_with_approved_main(self, mock_client, temp_tools_dir): """Should skip owner/name refs; approved main tool = empty unpublished.""" mock_client.get_my_tool_status.return_value = {"status": "approved"} # Tool depends on a registry tool self._create_tool(temp_tools_dir, "main-tool", deps=["official/summarize"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "official/summarize" not in result.unpublished assert result.unpublished == [] def test_detects_cycles(self, mock_client, temp_tools_dir): """Should detect and report circular dependencies.""" from cmdforge.registry_client import RegistryError mock_client.get_my_tool_status.side_effect = RegistryError( code="TOOL_NOT_FOUND", message="Not found" ) # a -> b -> a (cycle) self._create_tool(temp_tools_dir, "tool-a", deps=["tool-b"]) self._create_tool(temp_tools_dir, "tool-b", deps=["tool-a"]) result = gather_local_unpublished_deps(["tool-a"], mock_client, "testuser") assert len(result.cycles) > 0 # Cyclic nodes should NOT be in unpublished list assert "tool-a" not in result.unpublished assert "tool-b" not in result.unpublished def test_distinguishes_404_from_network_error(self, mock_client, temp_tools_dir): """404 = unpublished, other errors = skipped.""" from cmdforge.registry_client import RegistryError def status_side_effect(name): if name == "dep-404": raise RegistryError(code="TOOL_NOT_FOUND", message="Not found") elif name == "dep-network": raise RegistryError(code="CONNECTION_ERROR", message="Network error") return {"status": "approved"} mock_client.get_my_tool_status.side_effect = status_side_effect self._create_tool(temp_tools_dir, "dep-404") self._create_tool(temp_tools_dir, "dep-network") self._create_tool(temp_tools_dir, "main", deps=["dep-404", "dep-network"]) result = gather_local_unpublished_deps(["main"], mock_client, "testuser") assert "dep-404" in result.unpublished assert "dep-network" in result.skipped def test_published_dep_not_in_unpublished(self, mock_client, temp_tools_dir): """Already approved deps should not be in unpublished list.""" mock_client.get_my_tool_status.return_value = {"status": "approved"} self._create_tool(temp_tools_dir, "published-dep") self._create_tool(temp_tools_dir, "main-tool", deps=["published-dep"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "published-dep" not in result.unpublished assert result.unpublished == [] def test_rejected_dep_in_unpublished(self, mock_client, temp_tools_dir): """Rejected deps should be in unpublished list (need republishing).""" mock_client.get_my_tool_status.return_value = {"status": "rejected"} self._create_tool(temp_tools_dir, "rejected-dep") self._create_tool(temp_tools_dir, "main-tool", deps=["rejected-dep"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "rejected-dep" in result.unpublished def test_pending_dep_in_unpublished(self, mock_client, temp_tools_dir): """Pending deps should be in unpublished list (not yet usable).""" mock_client.get_my_tool_status.return_value = {"status": "pending"} self._create_tool(temp_tools_dir, "pending-dep") self._create_tool(temp_tools_dir, "main-tool", deps=["pending-dep"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "pending-dep" in result.unpublished def test_changes_requested_dep_in_unpublished(self, mock_client, temp_tools_dir): """Deps with changes_requested should be in unpublished list.""" mock_client.get_my_tool_status.return_value = {"status": "changes_requested"} self._create_tool(temp_tools_dir, "needs-changes-dep") self._create_tool(temp_tools_dir, "main-tool", deps=["needs-changes-dep"]) result = gather_local_unpublished_deps(["main-tool"], mock_client, "testuser") assert "needs-changes-dep" in result.unpublished