Files
pad/internal/server/task3585_create_key_test.go
T
xarmian c46ecc4cf8 feat(items): idempotency keys for item create (TASK-3585) (#2065)
A create carries op_key (body) or Idempotency-Key (header). The key is
claimed before the write and its answer (the created item's id) is stored
inside the create's own transaction, so a retry whose first answer was
lost returns that item instead of creating a twin. A refused create
releases its key; a key reused for a different create is 422
idempotency_key_reused; one still in flight is 409 idempotency_in_flight.
A replay whose item has since been deleted answers 410
idempotency_target_gone.

CLI: `pad item create --op-key`, a fresh key per invocation by default,
and one same-key retry of a 5xx or transport error, only when the server
advertises idempotency_keys. MCP: pad_item create accepts op_key on both
transports (remote maps it into the body; stdio passes --op-key).
ToolSurfaceVersion 0.90 -> 0.91 (additive).

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-11 13:06:44 -04:00

83 lines
3.1 KiB
Go

package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3585: idempotency keys on item create.
func TestTASK3585_CreateReplaysTheItemItMade(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
path := "/api/v1/workspaces/" + slug + "/collections/tasks/items"
post := func(key string, body map[string]any) (int, models.Item, http.Header) {
t.Helper()
rr := doRequestWithHeaders(srv, "POST", path, body, map[string]string{"Idempotency-Key": key})
var it models.Item
_ = json.Unmarshal(rr.Body.Bytes(), &it)
return rr.Code, it, rr.Header()
}
body := map[string]any{"title": "Keyed create", "fields": `{"status":"open"}`}
code, first, _ := post("c-1", body)
if code != http.StatusCreated || first.ID == "" {
t.Fatalf("first create: %d", code)
}
code, again, hdr := post("c-1", body)
if code != http.StatusCreated || again.ID != first.ID || hdr.Get("Idempotent-Replayed") != "true" {
t.Fatalf("replay: %d %s vs %s %v", code, again.ID, first.ID, hdr)
}
ws, _ := srv.store.GetWorkspaceBySlug(slug)
items, err := srv.store.ListItems(ws.ID, models.ItemListParams{})
if err != nil {
t.Fatal(err)
}
n := 0
for _, it := range items {
if it.Title == "Keyed create" {
n++
}
}
if n != 1 {
t.Fatalf("%d items titled 'Keyed create'; the retry created a twin", n)
}
if code, _, _ := post("c-1", map[string]any{"title": "Something else"}); code != http.StatusUnprocessableEntity {
t.Fatalf("a reused key: %d", code)
}
}
func TestTASK3585_RefusedCreateReleasesItsKey(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
path := "/api/v1/workspaces/" + slug + "/collections/tasks/items"
bad := doRequestWithHeaders(srv, "POST", path, map[string]any{"title": "x", "fields": `{"status":"no-such-status"}`}, map[string]string{"Idempotency-Key": "c-2"})
if bad.Code < 400 {
t.Fatalf("premise: the bad create must be refused; got %d", bad.Code)
}
ok := doRequestWithHeaders(srv, "POST", path, map[string]any{"title": "x", "fields": `{"status":"open"}`}, map[string]string{"Idempotency-Key": "c-2"})
if ok.Code != http.StatusCreated || ok.Header().Get("Idempotent-Replayed") != "" {
t.Fatalf("corrected retry with the same key: %d %s", ok.Code, ok.Body.String())
}
}
// The CLI and MCP send the key as op_key in the body, not the header.
func TestTASK3585_CreateBodyKeyReplays(t *testing.T) {
srv := testServer(t)
slug := createWSWithCollections(t, srv)
path := "/api/v1/workspaces/" + slug + "/collections/tasks/items"
body := map[string]any{"title": "Body keyed", "fields": `{"status":"open"}`, "op_key": "b-1"}
first := doRequest(srv, "POST", path, body)
if first.Code != http.StatusCreated {
t.Fatalf("first create: %d %s", first.Code, first.Body.String())
}
again := doRequest(srv, "POST", path, body)
var a, b models.Item
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if again.Code != http.StatusCreated || a.ID == "" || a.ID != b.ID || again.Header().Get("Idempotent-Replayed") != "true" {
t.Fatalf("body-key replay: %d %q vs %q", again.Code, b.ID, a.ID)
}
}