Files
pad/internal/items/validate_multi_relation_test.go
T
xarmian c1076ce15f fix: a relation's "no target" has one stored form, and required means it names one (BUG-3028) (#1427)
* 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
2026-09-22 08:08:15 -04:00

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")
}
}