e336e31812
Collections declare a `fields:` schema in `.collection.yaml`; entries carry typed metadata (enum/tags/text). New central `metadata_schema` module parses the schema leniently (INV-3) and validates entry values — advisory at read (corpus mirror flags violations as `metadata_malformed` without hard-failing), the enforcement point for the write boundary (edit endpoints land SLICE-4/5). - app/metadata_schema.py: parse_fields (lenient/normalizing) + validate - registry.parse_collection_manifest reads `fields:` into collection config - collections.get_collection unpacks `fields`; served by the collection API - cache._refresh_collection_corpus validates each entry advisory-only Non-breaking, opt-in: no `fields:` → unchanged (INV-5); default `document` collection declares none (N=1 unchanged). No DB migration — schema rides in collections.config_json. SLICE-2 of docs/design/2026-06-06-configurable-collection-metadata.md §7.2. Backend suite green (601 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
178 lines
7.1 KiB
Python
178 lines
7.1 KiB
Python
"""SLICE-1 integration — the corpus mirror reads sidecars (dual-read) and
|
|
derives the malformed flag (PUC-6, INV-3/INV-6).
|
|
|
|
Per docs/design/2026-06-06-configurable-collection-metadata.md §6.2-6.3.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
from app import cache, db, gitea as gitea_mod
|
|
from app.config import load_config
|
|
|
|
from test_propose_vertical import ( # noqa: F401 (fixtures)
|
|
app_with_fake_gitea,
|
|
tmp_env,
|
|
)
|
|
|
|
|
|
def _refresh():
|
|
cfg = load_config()
|
|
asyncio.run(cache.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg)))
|
|
|
|
|
|
def _row(slug):
|
|
return db.conn().execute(
|
|
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
|
|
).fetchone()
|
|
|
|
|
|
def test_mirror_reads_metadata_from_sidecar(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
# A migrated entry: body-only .md + a sidecar holding the metadata.
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.md")] = {
|
|
"content": "Just the prose body.\n", "sha": "s1"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/sidecar-one.meta.yaml")] = {
|
|
"content": "slug: sidecar-one\ntitle: From Sidecar\nstate: active\ntags:\n- alpha\n",
|
|
"sha": "m1"}
|
|
_refresh()
|
|
|
|
row = _row("sidecar-one")
|
|
assert row is not None
|
|
assert row["title"] == "From Sidecar"
|
|
assert row["state"] == "active"
|
|
assert row["body"].strip() == "Just the prose body."
|
|
assert row["metadata_malformed"] == 0
|
|
|
|
|
|
def test_malformed_sidecar_flags_but_still_loads(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
# .md still has frontmatter; sidecar is malformed (a list, not a map).
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.md")] = {
|
|
"content": "---\nslug: bad-meta\ntitle: Legacy Title\nstate: active\n---\n\nBody.\n",
|
|
"sha": "b1"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-meta.meta.yaml")] = {
|
|
"content": "- not\n- a\n- mapping\n", "sha": "b2"}
|
|
_refresh()
|
|
|
|
row = _row("bad-meta")
|
|
assert row is not None # INV-3: still loads
|
|
assert row["metadata_malformed"] == 1
|
|
# Falls back to the legacy .md frontmatter for the metadata.
|
|
assert row["title"] == "Legacy Title"
|
|
|
|
|
|
def test_malformed_flag_surfaces_in_catalog_api(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app) as client:
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.md")] = {
|
|
"content": "---\nslug: flagged\ntitle: Flagged\nstate: active\n---\n\nB.\n",
|
|
"sha": "f1"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/flagged.meta.yaml")] = {
|
|
"content": "just a scalar\n", "sha": "f2"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/clean.md")] = {
|
|
"content": "---\nslug: clean\ntitle: Clean\nstate: active\n---\n\nB.\n",
|
|
"sha": "c1"}
|
|
_refresh()
|
|
|
|
items = {i["slug"]: i for i in client.get("/api/rfcs").json()["items"]}
|
|
assert items["flagged"]["metadata_malformed"] is True
|
|
assert items["clean"]["metadata_malformed"] is False
|
|
|
|
# And on the detail view.
|
|
assert client.get("/api/rfcs/flagged").json()["metadata_malformed"] is True
|
|
|
|
|
|
def test_malformed_sidecar_on_migrated_entry_still_loads_flagged(app_with_fake_gitea):
|
|
# INV-3 regression: a migrated (body-only .md) entry whose sidecar is
|
|
# corrupt must NOT vanish from the catalog — it loads (slug from the
|
|
# filename stem) and is flagged malformed.
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.md")] = {
|
|
"content": "Just the body, no frontmatter.\n", "sha": "o1"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/orphaned.meta.yaml")] = {
|
|
"content": "- corrupt\n- list\n", "sha": "o2"}
|
|
_refresh()
|
|
|
|
row = _row("orphaned")
|
|
assert row is not None # did not vanish
|
|
assert row["metadata_malformed"] == 1
|
|
assert row["body"].strip() == "Just the body, no frontmatter."
|
|
|
|
|
|
def _set_default_fields_schema(schema):
|
|
# apply_registry leaves the default collection's config_json untouched, so a
|
|
# schema set here survives a corpus refresh (refresh_meta_repo only).
|
|
db.conn().execute(
|
|
"UPDATE collections SET config_json = ? WHERE id = 'default'",
|
|
(json.dumps({"fields": schema}),))
|
|
|
|
|
|
# ---- §22.4a SLICE-2: advisory schema validation at ingest (INV-3) ----
|
|
|
|
def test_schema_violation_flags_malformed(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_set_default_fields_schema(
|
|
{"priority": {"type": "enum", "values": ["P0", "P1", "P2"]}})
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/bad-prio.md")] = {
|
|
"content": "---\nslug: bad-prio\ntitle: Bad\nstate: active\npriority: P9\n---\n\nB.\n",
|
|
"sha": "bp1"}
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/good-prio.md")] = {
|
|
"content": "---\nslug: good-prio\ntitle: Good\nstate: active\npriority: P0\n---\n\nB.\n",
|
|
"sha": "gp1"}
|
|
_refresh()
|
|
|
|
assert _row("bad-prio")["metadata_malformed"] == 1 # INV-3: flagged
|
|
assert _row("bad-prio")["title"] == "Bad" # still loads
|
|
assert _row("good-prio")["metadata_malformed"] == 0
|
|
|
|
|
|
def test_schema_ignores_undeclared_keys(app_with_fake_gitea):
|
|
# INV-7: keys the schema doesn't declare ride along and never flag malformed.
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
_set_default_fields_schema(
|
|
{"priority": {"type": "enum", "values": ["P0", "P1"]}})
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/extra-key.md")] = {
|
|
"content": "---\nslug: extra-key\ntitle: Extra\nstate: active\nowner: hasan\n---\n\nB.\n",
|
|
"sha": "ek1"}
|
|
_refresh()
|
|
|
|
assert _row("extra-key")["metadata_malformed"] == 0
|
|
|
|
|
|
def test_no_schema_never_flags(app_with_fake_gitea):
|
|
# INV-5: a collection with no field schema validates nothing, even when an
|
|
# entry carries values that would fail a schema if one existed.
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/anything.md")] = {
|
|
"content": "---\nslug: anything\ntitle: Any\nstate: active\npriority: whatever\n---\n\nB.\n",
|
|
"sha": "an1"}
|
|
_refresh()
|
|
|
|
assert _row("anything")["metadata_malformed"] == 0
|
|
|
|
|
|
def test_legacy_collection_without_sidecars_unchanged(app_with_fake_gitea):
|
|
app, fake = app_with_fake_gitea
|
|
with TestClient(app):
|
|
fake.files[("wiggleverse", "meta", "main", "rfcs/legacy.md")] = {
|
|
"content": "---\nslug: legacy\ntitle: Legacy\nstate: super-draft\n---\n\nPitch.\n",
|
|
"sha": "l1"}
|
|
_refresh()
|
|
|
|
row = _row("legacy")
|
|
assert row is not None
|
|
assert row["title"] == "Legacy"
|
|
assert row["state"] == "super-draft"
|
|
assert row["body"].strip() == "Pitch."
|
|
assert row["metadata_malformed"] == 0
|