"""Contract tests for the catalog schema and source stack. Mirrors contracts/bundle-catalog.schema.md: source precedence project > user > built-in, install policy gating, payload parsing. """ from __future__ import annotations from pathlib import Path import yaml from specify_cli.bundler.models.catalog import ( BUILTIN_DEFAULT_STACK, CatalogSource, InstallPolicy, Scope, load_catalog_payload, load_source_stack, ) from specify_cli.bundler import BundlerError import pytest from tests.bundler_helpers import catalog_entry_dict, catalog_payload, make_project def test_non_integer_source_priority_raises_actionable_error(): with pytest.raises(BundlerError, match="non-integer priority"): CatalogSource.from_dict( {"id": "corp", "url": "https://corp/catalog.json", "priority": "high"}, Scope.PROJECT, ) def test_builtin_default_stack_when_no_config(tmp_path: Path): make_project(tmp_path) sources = load_source_stack(tmp_path) ids = [s.id for s in sources] assert ids == ["default", "community"] assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY assert all(s.scope is Scope.BUILTIN for s in sources) def test_project_config_overrides_same_id(tmp_path: Path): make_project(tmp_path) config = { "schema_version": "1.0", "catalogs": [ {"id": "default", "url": "file://local", "priority": 1, "install_policy": "install-allowed"}, {"id": "corp", "url": "https://corp/catalog.json", "priority": 0, "install_policy": "install-allowed"}, ], } (tmp_path / ".specify" / "bundle-catalogs.yml").write_text( yaml.safe_dump(config), encoding="utf-8" ) sources = load_source_stack(tmp_path) by_id = {s.id: s for s in sources} assert by_id["default"].scope is Scope.PROJECT assert by_id["default"].url == "file://local" # Highest precedence (lowest priority number) sorts first. assert sources[0].id == "corp" def test_user_scope_between_builtin_and_project(tmp_path: Path): make_project(tmp_path) user_dir = tmp_path / "userconf" user_dir.mkdir() (user_dir / "bundle-catalogs.yml").write_text( yaml.safe_dump( {"catalogs": [ {"id": "community", "url": "https://u", "priority": 2, "install_policy": "install-allowed"} ]} ), encoding="utf-8", ) sources = load_source_stack(tmp_path, user_config_dir=user_dir) by_id = {s.id: s for s in sources} # User overrode the built-in community policy to install-allowed. assert by_id["community"].scope is Scope.USER assert by_id["community"].install_allowed is True def test_load_payload_parses_entries(): payload = catalog_payload({"demo-bundle": catalog_entry_dict()}) entries = load_catalog_payload(payload) assert "demo-bundle" in entries assert entries["demo-bundle"].version == "1.2.0" assert entries["demo-bundle"].provides["presets"] == 1 def test_builtin_default_stack_constant_shape(): ids = {raw["id"] for raw in BUILTIN_DEFAULT_STACK} assert ids == {"default", "community"} def test_catalog_entry_rejects_string_tags(): from specify_cli.bundler.models.catalog import CatalogEntry data = catalog_entry_dict("demo") data["tags"] = "not-a-list" with pytest.raises(BundlerError, match="'tags' must be a list"): CatalogEntry.from_dict(data) def test_catalog_entry_rejects_non_boolean_verified(): from specify_cli.bundler.models.catalog import CatalogEntry data = catalog_entry_dict("demo") data["verified"] = "false" # truthy string must not mark the entry verified with pytest.raises(BundlerError, match="'verified' must be a boolean"): CatalogEntry.from_dict(data) def test_load_payload_rejects_id_key_mismatch(): # The enclosing key is authoritative; an entry whose own id disagrees with # the key must be rejected so a catalog can't list a spoofed/unresolvable id. payload = catalog_payload({"demo-bundle": catalog_entry_dict("other-id")}) with pytest.raises(BundlerError, match="id mismatch"): load_catalog_payload(payload) def test_load_payload_rejects_missing_entry_id(): entry = catalog_entry_dict("demo-bundle") entry["id"] = "" payload = catalog_payload({"demo-bundle": entry}) with pytest.raises(BundlerError, match="missing its 'id'"): load_catalog_payload(payload) def test_catalog_entry_rejects_non_mapping_requires(): from specify_cli.bundler.models.catalog import CatalogEntry data = catalog_entry_dict("demo") data["requires"] = "speckit>=0.1" with pytest.raises(BundlerError, match="'requires' must be a mapping"): CatalogEntry.from_dict(data) def test_catalog_entry_rejects_non_mapping_provides(): from specify_cli.bundler.models.catalog import CatalogEntry data = catalog_entry_dict("demo") data["provides"] = "extensions" with pytest.raises(BundlerError, match="'provides' must be a mapping"): CatalogEntry.from_dict(data)