Files
rfc-app/backend/tests/test_metadata_schema.py
T
Ben Stull e336e31812 v0.48.0 — SLICE-2: collection field schema + central validation (§22.4a)
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>
2026-06-07 17:06:04 -07:00

150 lines
4.6 KiB
Python

"""SLICE-2 unit tests — collection field schema + central validation.
Pure functions only (no DB / no Gitea). Per
docs/design/2026-06-06-configurable-collection-metadata.md §7.2 (SLICE-2):
`metadata_schema.parse_fields` (lenient schema parsing) and
`metadata_schema.validate` (advisory at read / enforcement point at write).
Honors INV-3 (never hard-fails), INV-5 (no-fields unchanged), INV-7 (undeclared
keys ride along).
"""
from __future__ import annotations
from app import metadata_schema as ms
# ---- parse_fields: normalization + leniency ----
def test_parse_fields_each_type():
raw = {
"priority": {"type": "enum", "values": ["P0", "P1", "P2"], "label": "Priority"},
"tags": {"type": "tags"},
"owner": {"type": "text"},
}
fields = ms.parse_fields(raw)
assert list(fields) == ["priority", "tags", "owner"] # order preserved
assert fields["priority"] == {
"type": "enum",
"values": ["P0", "P1", "P2"],
"label": "Priority",
}
assert fields["tags"] == {"type": "tags"}
assert fields["owner"] == {"type": "text"}
def test_parse_fields_controlled_tags_keeps_values():
fields = ms.parse_fields({"area": {"type": "tags", "values": ["a", "b"]}})
assert fields["area"] == {"type": "tags", "values": ["a", "b"]}
def test_parse_fields_missing_block_is_empty():
assert ms.parse_fields(None) == {}
assert ms.parse_fields({}) == {}
def test_parse_fields_non_mapping_block_skipped():
assert ms.parse_fields(["not", "a", "mapping"]) == {}
assert ms.parse_fields("nope") == {}
def test_parse_fields_enum_without_values_skipped():
# enum requires a non-empty values list — skipped, not fatal.
assert ms.parse_fields({"p": {"type": "enum"}}) == {}
assert ms.parse_fields({"p": {"type": "enum", "values": []}}) == {}
def test_parse_fields_unknown_type_skipped():
fields = ms.parse_fields(
{"good": {"type": "text"}, "bad": {"type": "ref"}, "huh": {"type": "frob"}}
)
assert list(fields) == ["good"]
def test_parse_fields_non_mapping_def_skipped():
fields = ms.parse_fields({"good": {"type": "text"}, "bad": "scalar"})
assert list(fields) == ["good"]
def test_parse_fields_values_coerced_to_str_list():
fields = ms.parse_fields({"p": {"type": "enum", "values": [0, 1, 2]}})
assert fields["p"]["values"] == ["0", "1", "2"]
# ---- validate: advisory problem reporting ----
SCHEMA = ms.parse_fields(
{
"priority": {"type": "enum", "values": ["P0", "P1", "P2"]},
"tags": {"type": "tags"},
"area": {"type": "tags", "values": ["checkout", "cart"]},
"owner": {"type": "text"},
}
)
def test_validate_happy():
values = {
"priority": "P0",
"tags": ["anything", "free"],
"area": ["checkout"],
"owner": "ben",
}
assert ms.validate(values, SCHEMA) == []
def test_validate_absent_fields_ok():
# A declared field that the entry omits is fine (no required fields in v1).
assert ms.validate({}, SCHEMA) == []
def test_validate_enum_bad_value():
problems = ms.validate({"priority": "P9"}, SCHEMA)
assert [p.field for p in problems] == ["priority"]
assert problems[0].code == "not-in-values"
def test_validate_enum_wrong_type():
problems = ms.validate({"priority": ["P0"]}, SCHEMA)
assert problems[0].field == "priority"
assert problems[0].code == "wrong-type"
def test_validate_tags_free_form_ok():
assert ms.validate({"tags": ["x", "y", "z"]}, SCHEMA) == []
def test_validate_tags_wrong_type():
problems = ms.validate({"tags": "notalist"}, SCHEMA)
assert problems[0].field == "tags"
assert problems[0].code == "wrong-type"
def test_validate_controlled_tags_bad_member():
problems = ms.validate({"area": ["checkout", "nope"]}, SCHEMA)
assert problems[0].field == "area"
assert problems[0].code == "not-in-values"
def test_validate_text_wrong_type():
problems = ms.validate({"owner": ["a", "b"]}, SCHEMA)
assert problems[0].field == "owner"
assert problems[0].code == "wrong-type"
def test_validate_undeclared_keys_ignored():
# INV-7: keys outside the schema ride along untouched, never flagged.
assert ms.validate({"random": "value", "slug": "x", "title": "y"}, SCHEMA) == []
def test_validate_empty_schema_no_problems():
# INV-5: a collection with no fields validates everything as clean.
assert ms.validate({"priority": "anything", "x": 1}, {}) == []
def test_problem_as_dict():
p = ms.Problem(field="priority", code="not-in-values", message="bad")
assert p.as_dict() == {
"field": "priority",
"code": "not-in-values",
"message": "bad",
}