Files
pad/internal/store/items_fieldpatch_test.go
T
xarmian 919c2975c2 fix(items): note and decide append server-side under the write lock, so they no longer revert a concurrent field write (BUG-3056) (#1458)
* wip(BUG-3056): server-side note/decide append, capability-gated CLI

* test(BUG-3056): server append tests

* test(BUG-3056): CLI capability gate tests

* test(BUG-3056): store append tests on both backends; CLAUDE.md API note

* fix(BUG-3056): keep the remote /mcp door's created_by label (codex r1)

* docs(BUG-3056): CLAUDE.md names the remote door's author label
2026-09-23 07:46:45 -04:00

331 lines
11 KiB
Go

package store
import (
"encoding/json"
"errors"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// decodeFields is a small test helper: parse an item's fields JSON into a map.
func decodeFields(t *testing.T, fieldsJSON string) map[string]any {
t.Helper()
m := map[string]any{}
if fieldsJSON == "" || fieldsJSON == "{}" {
return m
}
if err := json.Unmarshal([]byte(fieldsJSON), &m); err != nil {
t.Fatalf("decode fields %q: %v", fieldsJSON, err)
}
return m
}
// TestMergeFieldsPatch exercises the pure shallow-merge helper directly:
// set, delete-on-null, orphan-key preservation, and empty-base handling.
func TestMergeFieldsPatch(t *testing.T) {
t.Parallel()
cases := []struct {
name string
current string
patch map[string]any
want map[string]any
}{
{
name: "set one key preserves the rest",
current: `{"status":"open","priority":"high"}`,
patch: map[string]any{"status": "done"},
want: map[string]any{"status": "done", "priority": "high"},
},
{
name: "null deletes a key",
current: `{"status":"open","priority":"high"}`,
patch: map[string]any{"priority": nil},
want: map[string]any{"status": "open"},
},
{
name: "add a new orphan key",
current: `{"status":"open"}`,
patch: map[string]any{"custom": "x"},
want: map[string]any{"status": "open", "custom": "x"},
},
{
name: "empty base",
current: "",
patch: map[string]any{"status": "open"},
want: map[string]any{"status": "open"},
},
{
name: "empty-object base",
current: "{}",
patch: map[string]any{"status": "open"},
want: map[string]any{"status": "open"},
},
{
name: "delete a key that isn't there is a no-op",
current: `{"status":"open"}`,
patch: map[string]any{"missing": nil},
want: map[string]any{"status": "open"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, err := mergeFieldsPatch(tc.current, tc.patch)
if err != nil {
t.Fatalf("mergeFieldsPatch: %v", err)
}
var gotMap map[string]any
if err := json.Unmarshal([]byte(got), &gotMap); err != nil {
t.Fatalf("merged JSON invalid: %v (%q)", err, got)
}
if len(gotMap) != len(tc.want) {
t.Fatalf("key count: got %v want %v", gotMap, tc.want)
}
for k, v := range tc.want {
if gotMap[k] != v {
t.Errorf("key %q: got %v want %v", k, gotMap[k], v)
}
}
})
}
}
// TestUpdateItemFieldsPatchMergesNotReplaces is the core race-fix regression
// (IDEA-1480 / TASK-2022): a FieldsPatch update touches ONE key and leaves the
// rest of the fields blob intact, unlike a full `fields` write which replaces
// everything.
func TestUpdateItemFieldsPatchMergesNotReplaces(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "FieldPatch")
col := createTestCollection(t, s, ws.ID, "Tasks")
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
Title: "Item",
Fields: `{"status":"open","priority":"high"}`,
})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
// Patch only status; priority must survive.
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"status": "done"},
})
if err != nil {
t.Fatalf("UpdateItem (patch): %v", err)
}
fields := decodeFields(t, updated.Fields)
if fields["status"] != "done" {
t.Errorf("status: got %v want done", fields["status"])
}
if fields["priority"] != "high" {
t.Errorf("priority clobbered: got %v want high (the whole point of field-level merge)", fields["priority"])
}
}
// TestUpdateItemFieldsPatchNullDeletes verifies the JSON-null delete sentinel
// removes a single key while preserving the others.
func TestUpdateItemFieldsPatchNullDeletes(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "FieldPatchDelete")
col := createTestCollection(t, s, ws.ID, "Tasks")
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
Title: "Item",
Fields: `{"status":"open","priority":"high"}`,
})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"priority": nil},
})
if err != nil {
t.Fatalf("UpdateItem (patch delete): %v", err)
}
fields := decodeFields(t, updated.Fields)
if _, ok := fields["priority"]; ok {
t.Errorf("priority should have been deleted, got %v", fields["priority"])
}
if fields["status"] != "open" {
t.Errorf("status: got %v want open", fields["status"])
}
}
// TestUpdateItemFieldsPatchSequentialNoClobber simulates the concurrent
// single-field writes IDEA-1480 describes, but sequentially against the store:
// two independent patches (status, then a custom meta key) must BOTH survive.
// Before field-level merge, the second full-blob write would have overwritten
// the first if it had been built from a stale read.
func TestUpdateItemFieldsPatchSequentialNoClobber(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "FieldPatchSeq")
col := createTestCollection(t, s, ws.ID, "Tasks")
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
Title: "Item",
Fields: `{"status":"open"}`,
})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
if _, err := s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"status": "done"},
}); err != nil {
t.Fatalf("UpdateItem patch 1: %v", err)
}
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"pad_source_url": "https://example.com"},
})
if err != nil {
t.Fatalf("UpdateItem patch 2: %v", err)
}
fields := decodeFields(t, updated.Fields)
if fields["status"] != "done" {
t.Errorf("first patch lost: status got %v want done", fields["status"])
}
if fields["pad_source_url"] != "https://example.com" {
t.Errorf("second patch lost: pad_source_url got %v", fields["pad_source_url"])
}
}
// TestUpdateItemExpectedUpdatedAtMatch: a matching optimistic-concurrency
// token lets the update through.
func TestUpdateItemExpectedUpdatedAtMatch(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "OCCMatch")
col := createTestCollection(t, s, ws.ID, "Tasks")
item := createTestItem(t, s, ws.ID, col.ID, "Item", "body")
expected := item.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z07:00")
patch := map[string]any{"status": "done"}
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
ExpectedUpdatedAt: expected,
FieldsPatch: patch,
})
if err != nil {
t.Fatalf("UpdateItem with matching expected_updated_at should succeed: %v", err)
}
if got := decodeFields(t, updated.Fields)["status"]; got != "done" {
t.Errorf("status: got %v want done", got)
}
}
// TestUpdateItemExpectedUpdatedAtConflict: a stale token is rejected with
// *UpdateConflictError, and the item is left unchanged.
func TestUpdateItemExpectedUpdatedAtConflict(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "OCCConflict")
col := createTestCollection(t, s, ws.ID, "Tasks")
item := createTestItem(t, s, ws.ID, col.ID, "Item", "body")
// A timestamp that definitely doesn't match the row's updated_at.
stale := "2000-01-01T00:00:00Z"
_, err := s.UpdateItem(item.ID, models.ItemUpdate{
ExpectedUpdatedAt: stale,
FieldsPatch: map[string]any{"status": "done"},
})
if err == nil {
t.Fatal("expected UpdateConflictError, got nil")
}
var conflict *UpdateConflictError
if !errors.As(err, &conflict) {
t.Fatalf("expected *UpdateConflictError, got %T: %v", err, err)
}
if conflict.ExpectedUpdatedAt != stale {
t.Errorf("conflict.ExpectedUpdatedAt: got %q want %q", conflict.ExpectedUpdatedAt, stale)
}
if conflict.ActualUpdatedAt.IsZero() {
t.Error("conflict.ActualUpdatedAt should carry the row's real timestamp")
}
// The write must NOT have landed.
reread, err := s.GetItem(item.ID)
if err != nil {
t.Fatalf("GetItem: %v", err)
}
if got := decodeFields(t, reread.Fields)["status"]; got != "open" {
t.Errorf("conflicting update should be a no-op; status got %v want open", got)
}
}
// TestUpdateItemAppendsStructuredEntriesUnderTheLock (BUG-3056): the minted
// entries are appended to the row UpdateItem re-reads under its lock, after
// any fields_patch is merged onto it, so neither the patch nor the entries
// already stored are lost. Runs on Postgres under `make test-pg` (testStore).
func TestUpdateItemAppendsStructuredEntriesUnderTheLock(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "Append")
col := createTestCollection(t, s, ws.ID, "Tasks")
seed, err := models.AppendImplementationNote(`{"status":"open","priority":"high"}`,
models.ItemImplementationNote{ID: "note-old", Summary: "earlier"})
if err != nil {
t.Fatalf("seed: %v", err)
}
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Item", Fields: seed})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"status": "done"},
ImplementationNoteToAppend: &models.ItemImplementationNote{ID: "note-new", Summary: "later"},
DecisionToAppend: &models.ItemDecisionLogEntry{ID: "decision-new", Decision: "go"},
})
if err != nil {
t.Fatalf("UpdateItem: %v", err)
}
fields := decodeFields(t, updated.Fields)
if fields["status"] != "done" || fields["priority"] != "high" {
t.Errorf("patch not applied onto the locked row: %v", fields)
}
notes := models.ExtractItemImplementationNotes(updated.Fields)
if len(notes) != 2 || notes[0].ID != "note-old" || notes[1].ID != "note-new" {
t.Errorf("notes = %+v, want the earlier entry then the appended one", notes)
}
if log := models.ExtractItemDecisionLog(updated.Fields); len(log) != 1 || log[0].ID != "decision-new" {
t.Errorf("decision_log = %+v", log)
}
}
// TestUpdateItemAppendRefusesUnreadableAndWritesNothing: the Append* guard
// runs inside the transaction, so a refusal rolls the whole update back —
// including a fields_patch that rode with it.
func TestUpdateItemAppendRefusesUnreadableAndWritesNothing(t *testing.T) {
t.Parallel()
s := testStore(t)
ws := createTestWorkspace(t, s, "AppendBad")
col := createTestCollection(t, s, ws.ID, "Tasks")
broken := `{"status":"open","decision_log":"[{\"decision\":\"legacy\"}]"}`
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "Item", Fields: broken})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
before := item.Fields
_, err = s.UpdateItem(item.ID, models.ItemUpdate{
FieldsPatch: map[string]any{"status": "done"},
DecisionToAppend: &models.ItemDecisionLogEntry{ID: "decision-new", Decision: "go"},
})
if !errors.Is(err, models.ErrStructuredFieldUnreadable) {
t.Fatalf("err = %v, want ErrStructuredFieldUnreadable", err)
}
after, err := s.GetItem(item.ID)
if err != nil {
t.Fatalf("GetItem: %v", err)
}
if after.Fields != before {
t.Errorf("a refused append changed the row:\n before %s\n after %s", before, after.Fields)
}
}