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>
150 lines
4.6 KiB
Python
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",
|
|
}
|