canned-prompts/service/tests/test_read_api.py
tegwick 5f47da8036 CANP-WP-0006 T03: read API
Search, versions, manifests, archives and the index, over HTTP.

The route shape is the decision worth recording. Package ids contain `/`, so
the obvious /packages/{id}/{version} is ambiguous under a greedy path
parameter. Rather than invent an HTTP-specific identifier, the routes speak the
format's own <registry>:<id>@<version> syntax and parse it — `:` and `@` are
both legal in a path segment, and each route keeps a distinct prefix so
greediness cannot swallow a neighbouring one. The API therefore exercises
section 3.2's reference notation instead of working around it.

A bare id present in more than one registry returns 409 with the candidates,
never a guess. 409 rather than 300 because the request is answerable once the
caller says which registry they meant. Omitting a version applies section
17.1's selector rules, so a prerelease is never chosen implicitly.

Validation is delegated to reference/, installed into the service environment
rather than reimplemented. One validator means the service and the CLI cannot
disagree about what a valid package is; a service accepting something the CLI
rejects would be the divergence this project exists to prevent. Importing it is
not changing it — reference/ stays the dependency-light conformance witness.

Storage keeps the format's distinctions: an immutable package version, its
files as content rather than parsed rows, and an index entry recording arrival.
Only reserved paths and manifest-referenced files are stored (section 2), and a
re-publish of identical content is accepted while different content under the
same id@version is a conflict (section 17).

Handles a real test-vs-production difference: SQLite autoincrements INTEGER
PRIMARY KEY only, never BIGINT, so the SQLite-backed tests could not insert a
row. BigInteger().with_variant(Integer, "sqlite") keeps BIGINT on PostgreSQL
while letting the tests exercise the same models and migration.

Verified live against a seeded store holding this repo's examples and four
helix-forge prompt packages. Service tests 11 -> 22.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bjefh8NUiEiahN4JLwoSKM

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 388925@bnt-lap001
Assistant-Session: 3507023f-e0fd-4a1e-9d90-a0d4217d1502
2026-09-06 20:23:25 +02:00

87 lines
3.4 KiB
Python

"""Read API (CANP-WP-0006 T03)."""
from __future__ import annotations
from fastapi.testclient import TestClient
def test_search_returns_stored_packages(seeded: TestClient) -> None:
body = seeded.get("/packages").json()
assert body["count"] == 2
assert {p["id"] for p in body["packages"]} == {
"practice/house-style",
"practice/pqrst-estimate",
}
def test_search_matches_summary_and_tags(seeded: TestClient) -> None:
assert seeded.get("/packages", params={"q": "pqrst"}).json()["count"] == 1
assert seeded.get("/packages", params={"q": "fragment"}).json()["count"] == 1
assert seeded.get("/packages", params={"q": "nothing-matches"}).json()["count"] == 0
def test_versions_of_a_bare_id(seeded: TestClient) -> None:
body = seeded.get("/packages/practice/pqrst-estimate").json()
assert body["id"] == "practice/pqrst-estimate"
assert [v["version"] for v in body["versions"]] == ["1.0.0"]
def test_manifest_by_qualified_reference(seeded: TestClient) -> None:
"""The API speaks the format's own <registry>:<id>@<version> syntax."""
body = seeded.get("/packages/local:practice/pqrst-estimate@1.0.0").json()
assert body["reference"] == "local:practice/pqrst-estimate@1.0.0"
assert "PQRST" in body["manifest"]
assert body["content_digest"].startswith("sha256:")
def test_unknown_package_is_404(seeded: TestClient) -> None:
assert seeded.get("/packages/nope/missing").status_code == 404
def test_unknown_version_is_404(seeded: TestClient) -> None:
assert seeded.get("/packages/practice/house-style@9.9.9").status_code == 404
def test_ambiguous_bare_id_is_409_not_a_guess(two_registries: TestClient) -> None:
"""§ 3.2: report the candidates, never choose for the caller."""
response = two_registries.get("/packages/practice/house-style")
assert response.status_code == 409
detail = response.json()["detail"]
assert "more than one registry" in detail
assert "house:practice/house-style" in detail
assert "local:practice/house-style" in detail
def test_qualifying_resolves_the_ambiguity(two_registries: TestClient) -> None:
body = two_registries.get("/packages/house:practice/house-style").json()
assert body["registry"] == "house"
def test_archive_returns_the_packaged_files(seeded: TestClient) -> None:
body = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json()
assert set(body["files"]) == {
"LICENSE",
"README.md",
"evals/canonical-fidelity.yaml",
"examples/basic.yaml",
"examples/with-rationale.yaml",
"prompt.md",
"prompt.yaml",
}
assert "PQRST-Estimate" in body["files"]["prompt.md"]["text"]
def test_archive_excludes_non_package_files(seeded: TestClient) -> None:
"""§ 2: only reserved paths and manifest-referenced files are stored."""
files = seeded.get("/archives/practice/pqrst-estimate@1.0.0").json()["files"]
assert not any(path.startswith(".") for path in files)
def test_index_records_arrival(seeded: TestClient) -> None:
body = seeded.get("/index").json()
assert body["count"] == 2
entry = next(e for e in body["entries"] if e["id"] == "practice/pqrst-estimate")
assert entry["method"] == "publish"
assert entry["source"] == "examples/pqrst-estimate"
assert entry["declared_source"] == "~/pqrst-practice/PqrstPrompt.md"
assert entry["included_at"] is not None