mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 06:19:49 +00:00
c1076ce15f
* fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028) A scalar relation had three stored spellings of "no target" (key absent, "" and whitespace-only), and required was enforced only against the first, so a required relation holding "" validated. The canonical form is now the one multi_relation uses: the key absent. Provenance decides the rest, per the lead's ruling. A blank a write SETS (create, fields_patch, a changed value in full fields, a move or copy override) is removed before validation, so a required one is refused as required. A patch blanking a required relation now gets the answer deleting it always got. A legacy blank a write only CARRIES is removed after validation and never refused: an untouched key under fields_patch, an unchanged full fields value, move, bulk update and bulk move, cross-workspace copy and its preflight, artifact import. On the patch path the store does this under its write lock (ItemUpdate.BlankRelationKeys), so a concurrent write's new value is never mistaken for a blank. An empty list filter on a scalar relation of a collection-scoped list (owner=) now matches all three spellings instead of only "". MCP ToolSurfaceVersion 0.40, a behavior bump. No migration: this instance has no relation fields, and the prod census is Dave's. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE * fix(items): a blank relation default is no default, so it cannot satisfy required (BUG-3028, codex round 1) Codex round 1 P2: a required relation whose schema default is "" had the blank injected by validation, which satisfied required, and was then normalised away after it, storing a required relation with no target. A blank default on a scalar relation now takes the no-default path: a required field is refused (the message names the blank default) and an optional one stays absent with nothing reported dropped. Claude-Session: https://claude.ai/code/session_01X4uYKs5ZPF28eCWfm8VPvE
102 lines
3.7 KiB
Go
102 lines
3.7 KiB
Go
package items
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// `multi_relation` shape validation (PLAN-2857 U4).
|
|
//
|
|
// This package is DB-free, so these tests are about SHAPE only — whether an
|
|
// element names a live item is the store's question. What is decided here is
|
|
// the emptiness rule, and it deliberately DIVERGES from scalar `relation`:
|
|
// an empty or whitespace-only element is REFUSED, where a scalar empty value
|
|
// is skipped downstream. See BUG-3028 for why there was no coherent scalar
|
|
// behaviour to inherit.
|
|
|
|
func multiRelationSchema(required bool) models.CollectionSchema {
|
|
return models.CollectionSchema{
|
|
Fields: []models.FieldDef{{
|
|
Key: "owners",
|
|
Label: "Owners",
|
|
Type: "multi_relation",
|
|
Collection: "people",
|
|
Required: required,
|
|
}},
|
|
}
|
|
}
|
|
|
|
func TestMultiRelation_AcceptsAnArrayOfReferences(t *testing.T) {
|
|
t.Parallel()
|
|
for _, val := range []any{
|
|
[]any{"11111111-1111-1111-1111-111111111111", "PEOP-3", "Ada Lovelace"},
|
|
[]string{"PEOP-3"},
|
|
[]any{}, // "no targets" is a legal SHAPE; normalizeEmptyRelationLists rewrites it before either validator traverses
|
|
} {
|
|
fields := map[string]any{"owners": val}
|
|
if err := ValidateFields(fields, multiRelationSchema(false)); err != nil {
|
|
t.Errorf("ValidateFields(%#v) = %v, want nil", val, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The three refusals, each naming the ELEMENT INDEX — a caller sending twelve
|
|
// references needs to know which one is wrong, and "field owners is invalid"
|
|
// does not tell them.
|
|
func TestMultiRelation_RefusesBadShapes(t *testing.T) {
|
|
t.Parallel()
|
|
cases := []struct {
|
|
name string
|
|
val any
|
|
want string
|
|
}{
|
|
{"a bare string is not an array", "PEOP-3", "must be an array"},
|
|
{"a number is not an array", 7, "must be an array"},
|
|
{"an object is not an array", map[string]any{"a": 1}, "must be an array"},
|
|
{"a non-string element", []any{"PEOP-3", 7}, "element 1"},
|
|
{"an empty element", []any{"PEOP-3", ""}, "element 1"},
|
|
{"a whitespace-only element", []any{" ", "PEOP-3"}, "element 0"},
|
|
{"a whitespace-only element, typed slice", []string{"PEOP-3", "\t"}, "element 1"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
fields := map[string]any{"owners": tc.val}
|
|
err := ValidateFields(fields, multiRelationSchema(false))
|
|
if err == nil {
|
|
t.Fatalf("ValidateFields(%#v) = nil, want an error", tc.val)
|
|
}
|
|
if !strings.Contains(err.Error(), tc.want) {
|
|
t.Errorf("error = %q, want it to contain %q", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// THE COUNTERFACTUAL, and the reason it is here rather than assumed: the
|
|
// VALIDATOR still accepts an empty scalar `relation`. BUG-3028 converged the
|
|
// two types at the write DOORS instead — each normalises a blank relation to
|
|
// key-absent before or after this validator by provenance (items.
|
|
// DropBlankRelations), so a set blank never reaches it as a value. Moving that
|
|
// rule into the validator would refuse carried legacy blanks it cannot tell
|
|
// from set ones; this leg is what would catch that change.
|
|
func TestMultiRelation_ScalarRelationIsUnchangedAndStillAcceptsEmpty(t *testing.T) {
|
|
t.Parallel()
|
|
schema := models.CollectionSchema{
|
|
Fields: []models.FieldDef{{
|
|
Key: "owner", Label: "Owner", Type: "relation", Collection: "people",
|
|
}},
|
|
}
|
|
for _, val := range []any{"", " ", "PEOP-3"} {
|
|
fields := map[string]any{"owner": val}
|
|
if err := ValidateFields(fields, schema); err != nil {
|
|
t.Errorf("scalar relation ValidateFields(%q) = %v, want nil (BUG-3028 territory, not U4's to change here)", val, err)
|
|
}
|
|
}
|
|
// ...and an ARRAY in a scalar relation is still refused.
|
|
if err := ValidateFields(map[string]any{"owner": []any{"PEOP-3"}}, schema); err == nil {
|
|
t.Error("scalar relation accepted an array, want an error")
|
|
}
|
|
}
|