migration
This commit is contained in:
@@ -1,78 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from personal_mcp.registry.ingest.document import MarkdownDocument
|
||||
from personal_mcp.registry.models.common import parse_docs_path
|
||||
from personal_mcp.registry.models.registry import DocsRegistry
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def make_markdown_document(
|
||||
relpath: str,
|
||||
*,
|
||||
content: str = "# body\n",
|
||||
frontmatter: str | None = None,
|
||||
) -> MarkdownDocument:
|
||||
"""Builds a markdown ingest document with deterministic defaults."""
|
||||
return MarkdownDocument(
|
||||
relpath=PurePosixPath(relpath),
|
||||
content=content,
|
||||
frontmatter=frontmatter,
|
||||
)
|
||||
|
||||
|
||||
def make_prompt_frontmatter_payload(
|
||||
*,
|
||||
prompt_id: str,
|
||||
name: str | None = None,
|
||||
version: str = "1.0.0",
|
||||
description: str = "demo prompt",
|
||||
tags: tuple[str, ...] = ("testing",),
|
||||
capabilities: tuple[str, ...] | None = None,
|
||||
arguments: dict[str, dict[str, Any]] | None = None,
|
||||
) -> str:
|
||||
"""Builds frontmatter payload YAML for prompt conversion tests."""
|
||||
canonical_name = name or prompt_id
|
||||
payload: dict[str, Any] = {
|
||||
"name": canonical_name,
|
||||
"description": description,
|
||||
"x-personal-mcp": {
|
||||
"id": prompt_id,
|
||||
"version": version,
|
||||
"tags": list(tags),
|
||||
"capabilities": list(capabilities or (f"resource://prompts/{canonical_name}/document",)),
|
||||
"arguments": arguments or {},
|
||||
},
|
||||
}
|
||||
return yaml.safe_dump(payload, sort_keys=False)
|
||||
|
||||
|
||||
def as_markdown(frontmatter_yaml: str, *, body: str = "# body\n") -> str:
|
||||
"""Wraps frontmatter YAML in markdown fence delimiters."""
|
||||
return f"---\n{frontmatter_yaml.strip()}\n---\n{body}"
|
||||
|
||||
|
||||
def assert_model_is_frozen(instance: Any, *, attr: str, value: Any) -> None:
|
||||
"""Asserts pydantic frozen model semantics."""
|
||||
with pytest.raises(ValidationError, match="Instance is frozen"):
|
||||
setattr(instance, attr, value)
|
||||
|
||||
|
||||
class TestGate5ContractValidation:
|
||||
"""Gate 5: canonical resource-path contracts."""
|
||||
class TestDocsPathValidation:
|
||||
"""Covers canonical resource-path contracts."""
|
||||
|
||||
def test_parse_docs_path_returns_pure_posix_path(self) -> None:
|
||||
"""Ensures boundary strings become path objects before publication."""
|
||||
path = parse_docs_path("skills/demo/SKILL.md")
|
||||
path = parse_docs_path("guides/demo.md")
|
||||
|
||||
assert path == PurePosixPath("skills/demo/SKILL.md")
|
||||
assert path == PurePosixPath("guides/demo.md")
|
||||
assert isinstance(path, PurePosixPath)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -80,51 +23,11 @@ class TestGate5ContractValidation:
|
||||
(
|
||||
"/absolute.md",
|
||||
"../outside.md",
|
||||
"skills\\demo\\SKILL.md",
|
||||
"skills//demo/SKILL.md",
|
||||
"skills/demo/README.txt",
|
||||
"guides\\demo.md",
|
||||
"guides//demo.md",
|
||||
"guides/demo.txt",
|
||||
),
|
||||
)
|
||||
def test_parse_docs_path_rejects_invalid_paths(self, value: str) -> None:
|
||||
"""Ensures non-canonical docs paths fail contract validation."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_docs_path(value)
|
||||
|
||||
|
||||
class TestGate6FreezeValidation:
|
||||
"""Gate 6: immutable in-memory registry snapshot semantics."""
|
||||
|
||||
def test_docs_registry_copies_mapping_inputs(self) -> None:
|
||||
"""Ensures registry snapshots are isolated from caller-owned mapping mutations."""
|
||||
index_path = PurePosixPath("index.md")
|
||||
source_docs = {index_path: "# index\n"}
|
||||
registry = DocsRegistry(
|
||||
docs_markdown_by_path=source_docs,
|
||||
docs_markdown_path_index=(index_path,),
|
||||
prompts_by_id={},
|
||||
prompts_in_load_order=(),
|
||||
prompts_summary_in_load_order=(),
|
||||
tag_to_prompt_ids={},
|
||||
)
|
||||
|
||||
source_docs[PurePosixPath("other.md")] = "# other\n"
|
||||
|
||||
assert PurePosixPath("other.md") not in registry.docs_markdown_by_path
|
||||
assert registry.docs_markdown_path_index == (index_path,)
|
||||
|
||||
def test_docs_registry_instance_is_frozen(self) -> None:
|
||||
"""Ensures frozen model prevents attribute reassignment."""
|
||||
registry = DocsRegistry(
|
||||
docs_markdown_by_path={},
|
||||
docs_markdown_path_index=(),
|
||||
prompts_by_id={},
|
||||
prompts_in_load_order=(),
|
||||
prompts_summary_in_load_order=(),
|
||||
tag_to_prompt_ids={},
|
||||
)
|
||||
|
||||
assert_model_is_frozen(
|
||||
registry,
|
||||
attr="docs_markdown_path_index",
|
||||
value=(PurePosixPath("index.md"),),
|
||||
)
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
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.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
|
||||
|
||||
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 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)
|
||||
@@ -1,47 +0,0 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
import pytest
|
||||
|
||||
from personal_mcp.registry.models.prompt import PromptArgumentEntry
|
||||
from personal_mcp.registry.models.registry import PromptRecord
|
||||
from personal_mcp.registry.models.registry import PromptSummaryPayload
|
||||
|
||||
pytestmark = pytest.mark.unit
|
||||
|
||||
|
||||
def _make_prompt_record() -> PromptRecord:
|
||||
return PromptRecord(
|
||||
prompt_id="demo-prompt",
|
||||
name="demo-prompt",
|
||||
description="demo prompt",
|
||||
version="0.9.0",
|
||||
tags=("testing",),
|
||||
capabilities=("resource://prompts/demo-prompt/document",),
|
||||
arguments={
|
||||
"topic": PromptArgumentEntry(
|
||||
title="Topic",
|
||||
required=True,
|
||||
description="topic to discuss",
|
||||
)
|
||||
},
|
||||
document_uri="resource://prompts/demo-prompt/document",
|
||||
document_relpath=PurePosixPath("prompts/demo-prompt/PROMPT.md"),
|
||||
document_content="# demo",
|
||||
)
|
||||
|
||||
|
||||
def test_prompt_summary_payload_from_record_shape() -> None:
|
||||
record = _make_prompt_record()
|
||||
|
||||
payload = PromptSummaryPayload.from_record(record).model_dump()
|
||||
|
||||
assert payload == {
|
||||
"id": "demo-prompt",
|
||||
"name": "demo-prompt",
|
||||
"description": "demo prompt",
|
||||
"tags": ["testing"],
|
||||
"capabilities": ["resource://prompts/demo-prompt/document"],
|
||||
"version": "0.9.0",
|
||||
"document_uri": "resource://prompts/demo-prompt/document",
|
||||
"detail_uri": "resource://catalog/prompts/demo-prompt",
|
||||
}
|
||||
Reference in New Issue
Block a user