mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
919c2975c2
* 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
331 lines
11 KiB
Go
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)
|
|
}
|
|
}
|