from __future__ import annotations import pytest from pydantic import ValidationError from personal_mcp.registry.ingest.prompt import PromptFilesBundle from personal_mcp.registry.load import _build_prompt_record from personal_mcp.registry.load import load_docs_registry from personal_mcp.registry.models.registry import PromptSummaryRecord from tests.registry.models.test_document_validation import as_markdown from tests.registry.models.test_document_validation import assert_model_is_frozen from tests.registry.models.test_document_validation import make_markdown_document from tests.registry.models.test_document_validation import make_prompt_frontmatter_payload from tests.registry.models.test_document_validation import make_skill_frontmatter_payload pytestmark = pytest.mark.unit def _make_prompt_bundle( *, slug: str, frontmatter: str | None, other_files: tuple[str, ...] = (), ) -> PromptFilesBundle: prompt = make_markdown_document( f"prompts/{slug}/PROMPT.md", frontmatter=frontmatter, ) other = tuple(make_markdown_document(f"prompts/{slug}/{filename}") for filename in other_files) return PromptFilesBundle(slug=slug, prompt=prompt, other=other) class TestPromptValidationGates: """Gate-oriented validation coverage for prompt conversion.""" class TestGate1LayoutValidation: """Gate 1: enforce required source shape before metadata parsing.""" def test_missing_frontmatter_fails_fast(self) -> None: """Ensures conversion rejects missing prompt frontmatter at the layout gate.""" bundle = _make_prompt_bundle(slug="initial", frontmatter=None) with pytest.raises(ValueError, match="missing YAML frontmatter"): _build_prompt_record(bundle=bundle) class TestGate2MetadataValidation: """Gate 2: validate prompt metadata via pydantic models.""" def test_rejects_non_semver_version(self) -> None: """Ensures semver violations fail during prompt model validation.""" frontmatter = make_prompt_frontmatter_payload(prompt_id="initial", version="not-semver") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) with pytest.raises(ValidationError, match="version must be semver"): _build_prompt_record(bundle=bundle) def test_rejects_invalid_argument_names(self) -> None: """Ensures prompt argument keys use Python-identifier naming rules.""" frontmatter = make_prompt_frontmatter_payload( prompt_id="initial", arguments={ "invalid-name": { "required": True, } }, ) bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) with pytest.raises(ValidationError, match="invalid prompt argument name"): _build_prompt_record(bundle=bundle) def test_rejects_missing_primary_capability(self) -> None: """Ensures canonical prompt document capability is required.""" frontmatter = make_prompt_frontmatter_payload( prompt_id="initial", capabilities=("resource://catalog/prompts_index",), ) bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) with pytest.raises(ValueError, match="capabilities must include"): _build_prompt_record(bundle=bundle) class TestGate3ResourceValidation: """Gate 3: resolve prompt document resources and argument schema.""" def test_assigns_canonical_document_uri(self) -> None: """Ensures prompt records emit canonical document URIs.""" frontmatter = make_prompt_frontmatter_payload(prompt_id="initial") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) assert record.document_uri == "resource://prompts/initial/document" assert record.document_relpath == "prompts/initial/PROMPT.md" def test_preserves_argument_schema(self) -> None: """Ensures argument metadata survives conversion unchanged.""" frontmatter = make_prompt_frontmatter_payload( prompt_id="initial", arguments={ "topic": { "required": True, "description": "topic to discuss", } }, ) bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) assert record.arguments["topic"].required is True assert record.arguments["topic"].description == "topic to discuss" class TestGate4GraphValidation: """Gate 4: validate cross-entity identifier coherence.""" def test_prompt_id_collision_with_skill_id_fails(self, tmp_path) -> None: """Ensures prompt and skill ids cannot collide in published registry.""" skill_dir = tmp_path / "skills" / "shared" prompt_dir = tmp_path / "prompts" / "shared" skill_dir.mkdir(parents=True) prompt_dir.mkdir(parents=True) skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared") prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared") (skill_dir / "SKILL.md").write_text(as_markdown(skill_frontmatter), encoding="utf-8") (prompt_dir / "PROMPT.md").write_text(as_markdown(prompt_frontmatter), encoding="utf-8") with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"): load_docs_registry(package_anchor="personal_mcp", docs_root=str(tmp_path)) class TestGate5ContractValidation: """Gate 5: validate model_dump contract shape for API surfaces.""" def test_prompt_record_model_dump_contains_contract_fields(self) -> None: """Ensures prompt record serialization includes canonical fields.""" frontmatter = make_prompt_frontmatter_payload(prompt_id="initial") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) dumped = record.model_dump() assert dumped["prompt_id"] == "initial" assert dumped["document_uri"] == "resource://prompts/initial/document" assert "arguments" in dumped assert "document_content" in dumped def test_prompt_summary_projection_stays_stable(self) -> None: """Ensures prompt summary shape remains deterministic for index responses.""" frontmatter = make_prompt_frontmatter_payload(prompt_id="initial") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) summary = PromptSummaryRecord.from_record(record) assert summary.model_dump() == { "prompt_id": "initial", "name": "initial", "description": "demo prompt", "tags": ("testing",), "capabilities": ("resource://prompts/initial/document",), "document_uri": "resource://prompts/initial/document", "version": "1.0.0", } class TestGate6FreezeValidation: """Gate 6: ensure immutable runtime records.""" def test_prompt_record_instance_is_frozen(self) -> None: """Ensures validated prompt records cannot be mutated after creation.""" frontmatter = make_prompt_frontmatter_payload(prompt_id="initial") bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) assert_model_is_frozen(record, attr="name", value="mutated") def test_argument_entry_values_are_frozen(self) -> None: """Ensures nested prompt argument entries are immutable after publication.""" frontmatter = make_prompt_frontmatter_payload( prompt_id="initial", arguments={ "topic": { "required": True, } }, ) bundle = _make_prompt_bundle(slug="initial", frontmatter=frontmatter) record = _build_prompt_record(bundle=bundle) with pytest.raises(ValidationError, match="Instance is frozen"): record.arguments["topic"].required = False