2cab53bc94
Test Vector Database Adaptors / Test MCP Vector DB Tools (push) Has been cancelled
Tests / Code Quality (Ruff & Mypy) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (macos-latest, 3.11) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (macos-latest, 3.12) (push) Has been cancelled
Tests / Tests (push) Has been cancelled
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers CLI - Convert documentation to AI skills dockerfile:Dockerfile name:skill-seekers]) (push) Has been cancelled
Docker Publish / Build and Push Docker Images (map[description:Skill Seekers MCP Server - 25 tools for AI assistants dockerfile:Dockerfile.mcp name:skill-seekers-mcp]) (push) Has been cancelled
Docker Publish / Test Docker Images (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.10) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.11) (push) Has been cancelled
Tests / Fast Unit Tests (parallel) (ubuntu-latest, 3.12) (push) Has been cancelled
Tests / Serial / Integration / E2E Tests (push) Has been cancelled
Tests / MCP Server Tests (push) Has been cancelled
Test Vector Database Adaptors / Test chroma Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test faiss Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test qdrant Adaptor (push) Has been cancelled
Test Vector Database Adaptors / Test weaviate Adaptor (push) Has been cancelled
530 lines
21 KiB
Python
530 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for ConfigPublisher class (config publishing to source repos)."""
|
|
|
|
import json
|
|
import os
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from skill_seekers.services.config_publisher import ConfigPublisher, detect_category
|
|
|
|
|
|
def _get_default_branch(repo_path):
|
|
"""Get the default branch name of a git repo (master or main)."""
|
|
import git
|
|
|
|
repo = git.Repo(repo_path)
|
|
return repo.active_branch.name
|
|
|
|
|
|
def _init_repo_with_main_branch(path):
|
|
"""Initialize a git repo ensuring the branch is named 'main'."""
|
|
import git
|
|
|
|
repo = git.Repo.init(path)
|
|
repo.config_writer().set_value("user", "name", "Test").release()
|
|
repo.config_writer().set_value("user", "email", "test@test.com").release()
|
|
|
|
# Create initial commit on whatever default branch
|
|
(path / "README.md").write_text("# Init\n")
|
|
repo.index.add(["README.md"])
|
|
repo.index.commit("Initial commit")
|
|
|
|
# Rename branch to 'main' if needed
|
|
if repo.active_branch.name != "main":
|
|
repo.git.branch("-m", repo.active_branch.name, "main")
|
|
|
|
return repo
|
|
|
|
|
|
class TestDetectCategory:
|
|
"""Test detect_category() keyword scoring."""
|
|
|
|
def test_game_engine_detected(self):
|
|
config = {"name": "godot-4", "description": "Godot game engine config"}
|
|
assert detect_category(config) == "game-engines"
|
|
|
|
def test_web_framework_detected(self):
|
|
config = {"name": "react-config", "description": "React web framework setup"}
|
|
assert detect_category(config) == "web-frameworks"
|
|
|
|
def test_ai_ml_detected(self):
|
|
config = {"name": "pytorch-training", "description": "PyTorch model training config"}
|
|
assert detect_category(config) == "ai-ml"
|
|
|
|
def test_database_detected(self):
|
|
config = {"name": "postgres-setup", "description": "PostgreSQL database config"}
|
|
assert detect_category(config) == "databases"
|
|
|
|
def test_devops_detected(self):
|
|
config = {"name": "docker-compose", "description": "Docker container orchestration"}
|
|
assert detect_category(config) == "devops"
|
|
|
|
def test_cloud_detected(self):
|
|
config = {"name": "aws-deployment", "description": "AWS cloud deployment config"}
|
|
assert detect_category(config) == "cloud"
|
|
|
|
def test_mobile_detected(self):
|
|
config = {"name": "flutter-app", "description": "Flutter mobile application config"}
|
|
assert detect_category(config) == "mobile"
|
|
|
|
def test_testing_detected(self):
|
|
config = {"name": "pytest-setup", "description": "Pytest testing framework"}
|
|
assert detect_category(config) == "testing"
|
|
|
|
def test_unknown_returns_custom(self):
|
|
config = {"name": "my-random-thing", "description": "Something unrelated"}
|
|
assert detect_category(config) == "custom"
|
|
|
|
def test_empty_config_returns_custom(self):
|
|
config = {}
|
|
assert detect_category(config) == "custom"
|
|
|
|
def test_name_only_matching(self):
|
|
config = {"name": "tailwind-theme"}
|
|
assert detect_category(config) == "css-frameworks"
|
|
|
|
def test_description_only_matching(self):
|
|
config = {"name": "my-config", "description": "Uses kubernetes for orchestration"}
|
|
assert detect_category(config) == "devops"
|
|
|
|
def test_highest_score_wins(self):
|
|
# "react" and "vue" both in web-frameworks, so web-frameworks should score higher
|
|
config = {"name": "react-vue-toolkit", "description": "React and Vue comparison"}
|
|
assert detect_category(config) == "web-frameworks"
|
|
|
|
def test_security_detected(self):
|
|
config = {"name": "oauth-setup", "description": "OAuth and JWT authentication"}
|
|
assert detect_category(config) == "security"
|
|
|
|
def test_messaging_detected(self):
|
|
config = {"name": "kafka-config", "description": "Apache Kafka messaging setup"}
|
|
assert detect_category(config) == "messaging"
|
|
|
|
|
|
class TestPublishErrors:
|
|
"""Test ConfigPublisher.publish() error cases."""
|
|
|
|
def test_publish_missing_config_file(self, tmp_path):
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
publisher.git_repo = MagicMock()
|
|
with pytest.raises(FileNotFoundError, match="Config file not found"):
|
|
publisher.publish(
|
|
config_path=tmp_path / "nonexistent.json",
|
|
source_name="test-source",
|
|
)
|
|
|
|
def test_publish_missing_name_field(self, tmp_path):
|
|
config_file = tmp_path / "bad_config.json"
|
|
config_file.write_text(json.dumps({"description": "No name field"}))
|
|
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
publisher.git_repo = MagicMock()
|
|
with pytest.raises(ValueError, match="must have a 'name' field"):
|
|
publisher.publish(
|
|
config_path=config_file,
|
|
source_name="test-source",
|
|
)
|
|
|
|
@patch.dict(os.environ, {}, clear=True)
|
|
def test_publish_missing_token(self, tmp_path):
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(json.dumps({"name": "test-config"}))
|
|
|
|
# Create a mock source that returns proper data
|
|
mock_source = {
|
|
"name": "test-source",
|
|
"git_url": "https://github.com/test/repo.git",
|
|
"branch": "main",
|
|
"token_env": "NONEXISTENT_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
mock_manager.list_sources.return_value = [mock_source]
|
|
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
publisher.git_repo = MagicMock()
|
|
|
|
with (
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
pytest.raises(RuntimeError, match="NONEXISTENT_TOKEN"),
|
|
):
|
|
publisher.publish(config_path=config_file, source_name="test-source")
|
|
|
|
def test_publish_source_not_found(self, tmp_path):
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(json.dumps({"name": "test-config"}))
|
|
|
|
mock_manager = MagicMock()
|
|
# Real SourceManager.get_source RAISES KeyError on a miss (it never
|
|
# returns falsy); publish() must translate that into a helpful ValueError.
|
|
mock_manager.get_source.side_effect = KeyError("Source 'nonexistent' not found")
|
|
mock_manager.list_sources.return_value = []
|
|
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
publisher.git_repo = MagicMock()
|
|
|
|
with (
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
pytest.raises(ValueError, match="not found"),
|
|
):
|
|
publisher.publish(config_path=config_file, source_name="nonexistent")
|
|
|
|
def test_publish_duplicate_without_force(self, tmp_path):
|
|
"""Config already exists in target repo and force=False should raise."""
|
|
import git as gitmodule
|
|
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(json.dumps({"name": "existing-config"}))
|
|
|
|
# Create a working repo with existing config
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
repo = _init_repo_with_main_branch(working_path)
|
|
|
|
# Add existing config
|
|
config_dir_in_repo = working_path / "configs" / "custom"
|
|
config_dir_in_repo.mkdir(parents=True)
|
|
(config_dir_in_repo / "existing-config.json").write_text(
|
|
json.dumps({"name": "existing-config"})
|
|
)
|
|
repo.index.add(["configs/custom/existing-config.json"])
|
|
repo.index.commit("Add existing config")
|
|
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
# Mock source manager
|
|
mock_source = {
|
|
"name": "test-source",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "fake-token"}),
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
pytest.raises(ValueError, match="already exists"),
|
|
):
|
|
publisher.publish(
|
|
config_path=config_file,
|
|
source_name="test-source",
|
|
category="custom",
|
|
force=False,
|
|
)
|
|
|
|
|
|
class TestPublishSuccess:
|
|
"""Test ConfigPublisher.publish() success path using a local bare git repo."""
|
|
|
|
def test_publish_happy_path(self, tmp_path):
|
|
"""Full success path: clone -> copy -> commit -> push."""
|
|
import git as gitmodule
|
|
|
|
# Create config file to publish
|
|
config_file = tmp_path / "my-config.json"
|
|
config_data = {"name": "my-config", "description": "A test config for pytest"}
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
# Create working repo with 'main' branch, then bare-clone as "remote"
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
_init_repo_with_main_branch(working_path)
|
|
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
# Mock source manager
|
|
mock_source = {
|
|
"name": "local-test",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
# Create publisher with custom cache dir
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "not-needed-for-file-protocol"}),
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
):
|
|
result = publisher.publish(
|
|
config_path=config_file,
|
|
source_name="local-test",
|
|
category="testing",
|
|
)
|
|
|
|
# Verify result
|
|
assert result["success"] is True
|
|
assert result["config_name"] == "my-config"
|
|
assert result["config_path"] == "configs/testing/my-config.json"
|
|
assert result["source"] == "local-test"
|
|
assert result["category"] == "testing"
|
|
assert len(result["commit_sha"]) == 8
|
|
assert result["branch"] == "main"
|
|
# MCP-07: a brand-new config is an "add".
|
|
assert "add" in result["message"]
|
|
|
|
# Verify the file exists in the cached clone
|
|
cached_repo = cache_dir / "source_local-test"
|
|
assert (cached_repo / "configs" / "testing" / "my-config.json").exists()
|
|
|
|
# Verify the config content was preserved
|
|
with open(cached_repo / "configs" / "testing" / "my-config.json") as f:
|
|
saved = json.load(f)
|
|
assert saved["name"] == "my-config"
|
|
|
|
def test_publish_force_overwrite(self, tmp_path):
|
|
"""Test that force=True overwrites an existing config."""
|
|
import git as gitmodule
|
|
|
|
config_file = tmp_path / "overwrite-config.json"
|
|
config_data = {"name": "overwrite-config", "description": "Updated version"}
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
# Create working repo with existing config
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
repo = _init_repo_with_main_branch(working_path)
|
|
|
|
# Pre-populate with existing config
|
|
configs_dir = working_path / "configs" / "custom"
|
|
configs_dir.mkdir(parents=True)
|
|
(configs_dir / "overwrite-config.json").write_text(
|
|
json.dumps({"name": "overwrite-config", "description": "Old version"})
|
|
)
|
|
repo.index.add(["configs/custom/overwrite-config.json"])
|
|
repo.index.commit("Add existing config")
|
|
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
mock_source = {
|
|
"name": "local-test",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "x"}),
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
):
|
|
result = publisher.publish(
|
|
config_path=config_file,
|
|
source_name="local-test",
|
|
category="custom",
|
|
force=True,
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["config_name"] == "overwrite-config"
|
|
# MCP-07: overwriting a pre-existing config is an "update", not an "add".
|
|
assert "update" in result["message"]
|
|
|
|
# Verify the file has updated content
|
|
cached_repo = cache_dir / "source_local-test"
|
|
with open(cached_repo / "configs" / "custom" / "overwrite-config.json") as f:
|
|
saved = json.load(f)
|
|
assert saved["description"] == "Updated version"
|
|
|
|
def test_publish_auto_detect_category(self, tmp_path):
|
|
"""Test that category='auto' auto-detects from config content."""
|
|
import git as gitmodule
|
|
|
|
config_file = tmp_path / "react-config.json"
|
|
config_data = {"name": "react-config", "description": "React web framework config"}
|
|
config_file.write_text(json.dumps(config_data))
|
|
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
_init_repo_with_main_branch(working_path)
|
|
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
mock_source = {
|
|
"name": "local-test",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "x"}),
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
):
|
|
result = publisher.publish(
|
|
config_path=config_file,
|
|
source_name="local-test",
|
|
category="auto",
|
|
)
|
|
|
|
assert result["success"] is True
|
|
assert result["category"] == "web-frameworks"
|
|
|
|
def test_publish_twice_reuses_cache(self, tmp_path):
|
|
"""Second publish to the same source hits the cached-repo re-pull path
|
|
(regression for ★MCP-13: it previously pulled via a tokenless origin)."""
|
|
import git as gitmodule
|
|
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
_init_repo_with_main_branch(working_path)
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
mock_source = {
|
|
"name": "local-test",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
def _publish(name):
|
|
cfg = tmp_path / f"{name}.json"
|
|
cfg.write_text(json.dumps({"name": name, "description": "x"}))
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "x"}),
|
|
patch(
|
|
"skill_seekers.services.source_manager.SourceManager",
|
|
return_value=mock_manager,
|
|
),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
):
|
|
return publisher.publish(
|
|
config_path=cfg, source_name="local-test", category="custom"
|
|
)
|
|
|
|
first = _publish("first-config")
|
|
second = _publish("second-config") # exercises the cached-repo pull branch
|
|
|
|
assert first["success"] is True
|
|
assert second["success"] is True
|
|
cached_repo = cache_dir / "source_local-test"
|
|
assert (cached_repo / "configs" / "custom" / "first-config.json").exists()
|
|
assert (cached_repo / "configs" / "custom" / "second-config.json").exists()
|
|
|
|
def test_publish_create_branch_twice_survives_leftover_branch(self, tmp_path):
|
|
"""Re-publishing with create_branch=True must not fail on the feature
|
|
branch left in the cache by the first publish (checkout -B, not -b)."""
|
|
import git as gitmodule
|
|
|
|
working_path = tmp_path / "working"
|
|
working_path.mkdir()
|
|
_init_repo_with_main_branch(working_path)
|
|
bare_repo_path = tmp_path / "remote.git"
|
|
gitmodule.Repo.clone_from(str(working_path), str(bare_repo_path), bare=True)
|
|
|
|
mock_source = {
|
|
"name": "local-test",
|
|
"git_url": f"file://{bare_repo_path}",
|
|
"branch": "main",
|
|
"token_env": "DUMMY_TOKEN",
|
|
}
|
|
mock_manager = MagicMock()
|
|
mock_manager.get_source.return_value = mock_source
|
|
|
|
cache_dir = tmp_path / "cache"
|
|
cache_dir.mkdir()
|
|
publisher = ConfigPublisher.__new__(ConfigPublisher)
|
|
from skill_seekers.services.git_repo import GitConfigRepo
|
|
|
|
publisher.git_repo = GitConfigRepo(cache_dir=str(cache_dir))
|
|
|
|
config_file = tmp_path / "config.json"
|
|
config_file.write_text(json.dumps({"name": "branchy-config"}))
|
|
|
|
with (
|
|
patch.dict(os.environ, {"DUMMY_TOKEN": "x"}),
|
|
patch("skill_seekers.services.source_manager.SourceManager", return_value=mock_manager),
|
|
patch("skill_seekers.cli.config_validator.validate_config", return_value=None),
|
|
):
|
|
first = publisher.publish(
|
|
config_path=config_file,
|
|
source_name="local-test",
|
|
category="custom",
|
|
create_branch=True,
|
|
)
|
|
# Change the content so the second commit's tree (and SHA) differs
|
|
# from the first — otherwise both publishes can produce the SAME
|
|
# commit within one second and the push is a no-op "up-to-date",
|
|
# hiding the non-fast-forward path this test must exercise.
|
|
config_file.write_text(json.dumps({"name": "branchy-config", "description": "v2"}))
|
|
# The cached repo is back on main but config/branchy-config still
|
|
# exists locally (and on the remote, pointing at the now-orphaned
|
|
# first commit) — the retry/force-update path must survive both.
|
|
second = publisher.publish(
|
|
config_path=config_file,
|
|
source_name="local-test",
|
|
category="custom",
|
|
create_branch=True,
|
|
force=True,
|
|
)
|
|
|
|
assert first["success"] is True
|
|
assert second["success"] is True
|
|
assert first["branch"] == "config/branchy-config"
|
|
cached_repo = gitmodule.Repo(cache_dir / "source_local-test")
|
|
assert cached_repo.active_branch.name == "main"
|
|
# The remote branch must point at the SECOND publish's commit.
|
|
remote = gitmodule.Repo(bare_repo_path)
|
|
pushed = remote.commit("config/branchy-config")
|
|
assert (
|
|
pushed.tree["configs/custom/branchy-config.json"].data_stream.read().decode()
|
|
== config_file.read_text()
|
|
)
|