560 lines
21 KiB
Python
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
|