#!/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() )