Files
pad/internal/store/unique_fields_test.go
T
xarmian fd95b79694 fix(BUG-2367): moves and copies never carry into a computed field, and a carried unique-field collision is dropped and named instead of 500ing or storing a duplicate (#1514)
MigrateFields matched fields by key and type only. On every migrate door
(single move, bulk move, cross-workspace copy and its preflight):

- a value was carried into a destination field marked `computed`. It is
  now dropped, with preflight reason `target_computed`.
- a value colliding on a destination `unique_scope` field answered 500
  on single move (invocation_slug, which has an index), leaked the SQL
  error through bulk move, and stored a duplicate on an unindexed unique
  field. Provenance decides now, as TASK-2878 did for relation referents:
  a CARRIED collision is dropped and reported as `not_unique`, and an
  override or schema default that collides is refused with 409 conflict.
  A required field emptied by the drop is refused as required.

One store check (UniqueFieldConflictsQ / DropCarriedUniqueCollisionsQ)
serves all four doors, including the tx-bound copy. The report names the
value, and names the item holding it only when the caller may see that
item. The activity row always uses the holder-free sentence.

Reporting, all additive and omitempty: warnings.not_unique on the move
response, the copy response and bulk updated[] rows; `detail` on the
preflight's dropped row; CLI copy dry run and result, plus `  dropped:`
lines on `item move` stdout; web copy dialog, move and copy toasts, and
the timeline move card. ToolSurfaceVersion 0.51 (behaviour bump).

The relation leg of the original report was already closed by TASK-2878.
Item 4 (terminal/abandoned state change) is PR 2. Out of scope and filed
separately: BUG-3200 (missing needs_value routes) and BUG-3202 (float64
precision; the migrateCopyFields comment now points there).

Claude-Session: https://claude.ai/code/session_01RYiqbqXmxQMoKHsGm8B77N
2026-09-24 13:12:32 -04:00

91 lines
3.3 KiB
Go

package store
import (
"encoding/json"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// BUG-2367. The not_unique report names the item holding the value ONLY when
// the caller may see it: the drop already says the value is taken, but the
// holder's ref would disclose an item behind a grant the caller lacks.
func TestDropCarriedUniqueCollisions_HolderNamedOnlyWhenVisible(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Unique WS")
schema := `{"fields":[{"key":"invocation_slug","label":"Slug","type":"text","unique_scope":"workspace_collection"},{"key":"code","label":"Code","type":"text"}]}`
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Runbooks", Schema: schema})
if err != nil {
t.Fatal(err)
}
holder, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{Title: "Holder", Fields: `{"invocation_slug":"day"}`})
if err != nil {
t.Fatal(err)
}
holder, err = s.GetItem(holder.ID)
if err != nil || holder.Ref == "" {
t.Fatalf("holder has no ref to name (err %v)", err)
}
var defs models.CollectionSchema
if err := json.Unmarshal([]byte(coll.Schema), &defs); err != nil {
t.Fatal(err)
}
cases := []struct {
name string
canSee RelationVisibilityFunc
holder string
message string
}{
{"visible holder", func(Queryer, string, *models.Item) (bool, error) { return true, nil },
holder.Ref, `invocation_slug "day" is taken by ` + holder.Ref},
{"hidden holder", func(Queryer, string, *models.Item) (bool, error) { return false, nil },
"", `invocation_slug "day" is already used by another item in Runbooks`},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
fields := map[string]any{"invocation_slug": "day", "code": "x"}
drops, err := s.DropCarriedUniqueCollisionsQ(s.Q(), tc.canSee, ws.ID, coll, defs.Fields, fields, nil)
if err != nil {
t.Fatal(err)
}
if len(drops) != 1 {
t.Fatalf("want one drop, got %+v", drops)
}
d := drops[0]
if d.Key != "invocation_slug" || d.Value != "day" || d.Holder != tc.holder || d.Message != tc.message {
t.Fatalf("drop = %+v, want holder %q message %q", d, tc.holder, tc.message)
}
if _, still := fields["invocation_slug"]; still {
t.Fatal("the colliding carried value was reported dropped but left in the map")
}
if fields["code"] != "x" {
t.Fatal("a field with no unique_scope was touched")
}
})
}
t.Run("a supplied value is left for the final check", func(t *testing.T) {
fields := map[string]any{"invocation_slug": "day"}
drops, err := s.DropCarriedUniqueCollisionsQ(s.Q(), nil, ws.ID, coll, defs.Fields, fields,
func(k string) bool { return k == "invocation_slug" })
if err != nil {
t.Fatal(err)
}
if len(drops) != 0 || fields["invocation_slug"] != "day" {
t.Fatalf("a supplied value must not be dropped: drops %+v fields %v", drops, fields)
}
keys, err := s.UniqueFieldConflictsQ(s.Q(), coll.ID, "", defs.Fields, fields)
if err != nil || len(keys) != 1 || keys[0] != "invocation_slug" {
t.Fatalf("the final check must refuse it: keys %v err %v", keys, err)
}
})
t.Run("an item never conflicts with itself", func(t *testing.T) {
keys, err := s.UniqueFieldConflictsQ(s.Q(), coll.ID, holder.ID, defs.Fields, map[string]any{"invocation_slug": "day"})
if err != nil || len(keys) != 0 {
t.Fatalf("keys %v err %v", keys, err)
}
})
}