Files
pad/internal/server/task3585_comment_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

90 lines
3.3 KiB
Go

package server
import (
"encoding/json"
"net/http"
"testing"
"github.com/PerpetualSoftware/pad/internal/models"
)
// TASK-3585: idempotency keys on comment create.
func commentKeyFixture(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": "Commented on", "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 + "/comments"
}
func countComments(t *testing.T, srv *Server, path, body string) int {
t.Helper()
rr := doRequest(srv, "GET", path, nil)
var cs []models.Comment
_ = json.Unmarshal(rr.Body.Bytes(), &cs)
n := 0
for _, c := range cs {
if c.Body == body {
n++
}
}
return n
}
func TestTASK3585_CommentReplaysTheCommentItMade(t *testing.T) {
srv, path := commentKeyFixture(t)
body := map[string]any{"body": "Keyed comment", "op_key": "k-1"}
first := doRequest(srv, "POST", path, body)
again := doRequest(srv, "POST", path, body)
var a, b models.Comment
_ = json.Unmarshal(first.Body.Bytes(), &a)
_ = json.Unmarshal(again.Body.Bytes(), &b)
if first.Code != http.StatusCreated || again.Code != http.StatusCreated || a.ID == "" || a.ID != b.ID || again.Header().Get("Idempotent-Replayed") != "true" {
t.Fatalf("replay: %d/%d %q vs %q", first.Code, again.Code, a.ID, b.ID)
}
if n := countComments(t, srv, path, "Keyed comment"); n != 1 {
t.Fatalf("%d comments; the retry made a twin", n)
}
if rr := doRequest(srv, "POST", path, map[string]any{"body": "Different", "op_key": "k-1"}); rr.Code != http.StatusUnprocessableEntity {
t.Fatalf("a reused key: %d", rr.Code)
}
}
func TestTASK3585_RefusedCommentReleasesItsKey(t *testing.T) {
srv, path := commentKeyFixture(t)
bad := doRequestWithHeaders(srv, "POST", path, map[string]any{"body": "Reply", "parent_id": "no-such-comment"}, map[string]string{"Idempotency-Key": "k-2"})
if bad.Code < 400 {
t.Fatalf("premise: a reply to a missing parent must be refused; got %d", bad.Code)
}
ok := doRequestWithHeaders(srv, "POST", path, map[string]any{"body": "Reply"}, map[string]string{"Idempotency-Key": "k-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())
}
}
func TestTASK3585_CommentReplayOfADeletedComment(t *testing.T) {
srv, path := commentKeyFixture(t)
body := map[string]any{"body": "Then deleted", "op_key": "k-3"}
first := doRequest(srv, "POST", path, body)
var c models.Comment
_ = json.Unmarshal(first.Body.Bytes(), &c)
if first.Code != http.StatusCreated || c.ID == "" {
t.Fatalf("create: %d", first.Code)
}
if rr := doRequest(srv, "DELETE", path+"/"+c.ID, nil); rr.Code >= 300 {
t.Fatalf("delete: %d %s", rr.Code, rr.Body.String())
}
again := doRequest(srv, "POST", path, body)
if again.Code != http.StatusGone || !json.Valid(again.Body.Bytes()) {
t.Fatalf("replay of a deleted comment: %d %s", again.Code, again.Body.String())
}
if n := countComments(t, srv, path, "Then deleted"); n != 0 {
t.Fatalf("the replay re-created the comment: %d", n)
}
}