CmdForge/tests/test_collection.py

560 lines
21 KiB
Python

"""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