Files
pad/internal/server/task3585_append_key_test.go
T
xarmian 493498c977 feat(items): idempotency keys for comments, notes and decisions; idempotency_target_gone is structured (TASK-3585) (#2068)
* fix(mcp): idempotency_target_gone is a structured code on both transports (TASK-3585)

A replayed create whose item has since been deleted answers 410
idempotency_target_gone. Remote MCP had no 410 arm, and the CLI wrote no
stdio marker for it, so it reached agents as a generic error. It is now
allow-listed, mapped on the remote 410 arm, and marked on stdio, with a
hint shared between internal/cli and internal/mcp.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* feat(items): idempotency keys for comment create (TASK-3585)

POST .../items/{ref}/comments accepts op_key (body) or Idempotency-Key.
The key's answer (the comment id) is recorded inside
CreateCommentWithActivity's transaction, so a retry whose first answer
was lost returns that comment instead of a twin. A refused comment
releases its key; a replay of a comment deleted since answers 410
idempotency_target_gone. `pad item comment --op-key` (a fresh key per
invocation by default) retries once behind the idempotency_keys
capability; MCP comment forwards op_key on both transports.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* feat(items): idempotency keys for note and decision appends (TASK-3585)

PATCH /workspaces/{ws}/items/{ref} accepts op_key (body) or
Idempotency-Key, but only on an append-only update
(append_implementation_note and/or append_decision, plus source); a key
with any other member is refused 400. The answer (the item id and the
minted entries) is recorded in the update's own transaction, so a retry
whose first answer was lost echoes the first entries in `appended`
instead of appending twice. The built-in and patch callers, which reach
this handler with their own key, are untouched.

`pad item note|decide --op-key` (fresh per invocation by default) retry
once behind the idempotency_keys capability; MCP note/decide forward
op_key. ToolSurfaceVersion 0.91 -> 0.92 (additive; also records the 410
now being structured). docs/api.md covers create, comment and append keys.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 14:38:15 -04:00

91 lines
3.4 KiB
Go

package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3585: idempotency keys on the note/decision appends.
func appendKeyFixture(t *testing.T) (*Server, string) {
t.Helper()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]any{"title": "Noted", "fields": `{"status":"open"}`})
if rr.Code != http.StatusCreated {
t.Fatalf("item: %d %s", rr.Code, rr.Body.String())
}
var it models.Item
_ = json.Unmarshal(rr.Body.Bytes(), &it)
return srv, "/api/v1/workspaces/" + slug + "/items/" + it.Slug
}
func notesOn(t *testing.T, srv *Server, path string) []models.ItemImplementationNote {
t.Helper()
rr := doRequest(srv, "GET", path, nil)
var it models.Item
_ = json.Unmarshal(rr.Body.Bytes(), &it)
return models.ExtractItemImplementationNotes(it.Fields)
}
func TestTASK3585_AppendReplaysTheEntryItMade(t *testing.T) {
srv, path := appendKeyFixture(t)
body := map[string]any{"append_implementation_note": map[string]any{"summary": "Keyed note"}, "source": "cli", "op_key": "n-1"}
first := doRequest(srv, "PATCH", path, body)
again := doRequest(srv, "PATCH", path, body)
var a, b models.Item
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if first.Code != http.StatusOK || again.Code != http.StatusOK || again.Header().Get("Idempotent-Replayed") != "true" {
t.Fatalf("replay: %d/%d %s", first.Code, again.Code, again.Body.String())
}
if a.Appended == nil || a.Appended.ImplementationNote == nil || b.Appended == nil || b.Appended.ImplementationNote == nil ||
a.Appended.ImplementationNote.ID != b.Appended.ImplementationNote.ID {
t.Fatalf("the replay must echo the first entry: %+v vs %+v", a.Appended, b.Appended)
}
if n := len(notesOn(t, srv, path)); n != 1 {
t.Fatalf("%d notes; the retry appended a twin", n)
}
other := map[string]any{"append_implementation_note": map[string]any{"summary": "Other"}, "op_key": "n-1"}
if rr := doRequest(srv, "PATCH", path, other); rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("a reused key: %d", rr.Code)
}
}
func TestTASK3585_KeyOnANonAppendUpdateIsRefused(t *testing.T) {
srv, path := appendKeyFixture(t)
for _, body := range []map[string]any{
{"title": "Renamed", "op_key": "u-1"},
{"append_decision": map[string]any{"decision": "D"}, "fields_patch": map[string]any{"status": "done"}, "op_key": "u-2"},
} {
rr := doRequest(srv, "PATCH", path, body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("%v: %d %s", body, rr.Code, rr.Body.String())
}
}
rr := doRequest(srv, "GET", path, nil)
var it models.Item
_ = json.Unmarshal(rr.Body.Bytes(), &it)
if it.Title != "Noted" {
t.Fatalf("a refused keyed update wrote: title %q", it.Title)
}
}
func TestTASK3585_AppendWithoutAKeyIsUnchanged(t *testing.T) {
srv, path := appendKeyFixture(t)
body := map[string]any{"append_decision": map[string]any{"decision": "Unkeyed"}}
for i := 0; i < 2; i++ {
if rr := doRequest(srv, "PATCH", path, body); rr.Code != http.StatusOK || rr.Header().Get("Idempotent-Replayed") != "" {
t.Fatalf("unkeyed append %d: %d", i, rr.Code)
}
}
rr := doRequest(srv, "GET", path, nil)
var it models.Item
_ = json.Unmarshal(rr.Body.Bytes(), &it)
if n := len(models.ExtractItemDecisionLog(it.Fields)); n != 2 {
t.Fatalf("two unkeyed appends must append twice: %d", n)
}
}