203 lines
8.8 KiB
Python
203 lines
8.8 KiB
Python
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)
|