from __future__ import annotations from pathlib import PurePosixPath import pytest from pydantic import ValidationError from personal_mcp.registry.ingest.document import MarkdownDocument from personal_mcp.registry.ingest.prompt import PromptFilesBundle from personal_mcp.registry.load import _build_prompt_record from personal_mcp.registry.load import get_docs_registry from personal_mcp.registry.models.registry import PromptSummaryRecord 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 == PurePosixPath("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, monkeypatch: pytest.MonkeyPatch) -> None: """Ensures prompt and skill ids cannot collide in published registry.""" skill_frontmatter = make_skill_frontmatter_payload(skill_id="shared") prompt_frontmatter = make_prompt_frontmatter_payload(prompt_id="shared") documents = { PurePosixPath("skills/shared/SKILL.md"): make_markdown_document( "skills/shared/SKILL.md", frontmatter=skill_frontmatter, ), PurePosixPath("prompts/shared/PROMPT.md"): make_markdown_document( "prompts/shared/PROMPT.md", frontmatter=prompt_frontmatter, ), } def fake_from_root(_cls, _root): return documents monkeypatch.setattr(MarkdownDocument, "from_root", classmethod(fake_from_root)) get_docs_registry.cache_clear() try: with pytest.raises(ValueError, match="prompt_id collides with existing skill_id"): get_docs_registry() finally: get_docs_registry.cache_clear() 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) assert_model_is_frozen(record.arguments["topic"], attr="required", value=False)