mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 03:42:06 +00:00
bed933d7fd
* feat(items): field-level PATCH + conflict envelope + version history Adds three related item-update primitives (TASK-2022 / IDEA-1480): - Field-level merge: PATCH `fields_patch` shallow-merges onto the item's current fields INSIDE the write transaction (null deletes a key), so concurrent single-field updates no longer clobber each other via the full-blob read-modify-write. `pad item update` and the MCP `pad_item.update` action now send only the changed keys. - Optimistic concurrency: optional `expected_updated_at` on update; on mismatch the store returns *UpdateConflictError and the handler emits the pad-structured-error/v1 conflict envelope (HTTP 409, code=update_conflict). Surfaced on CLI (`--expected-updated-at`) and MCP (`expected_updated_at`). - Read-only version history: `pad item history <ref>` (alias `versions`) and MCP `pad_item.history`, reusing the existing item_versions store + versions endpoint (no new store, no schema change). MCP ToolSurfaceVersion bumped 0.9 -> 1.0 (new action + param; update behavior change). No migration required. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS * fix(items): address Codex review — dispatcher fields_patch, OCC ordering, date/required guards Round 1+2 review fixes for TASK-2022: - HTTP MCP dispatcher (dispatch_http_advanced.go) now sends fields_patch (only changed keys) instead of a client-side merged full fields blob, and forwards expected_updated_at — remote MCP callers get the same race-free merge + optimistic concurrency the CLI/HTTP paths do. - ValidatePartialFields rejects null-deleting a schema-declared REQUIRED field (would otherwise persist a blob the full-update validator rejects). - Open-children guard on the fields_patch path merges the patch onto the IN-TX locked row inside the precheck (not a stale pre-lock preview), so a priority-only patch can't false-fire the guard. - Optimistic-concurrency check now runs BEFORE the open-children precheck in the store, so a stale expected_updated_at yields update_conflict (not open_children) — single in-tx re-read shared by both. - Date auto-population on the patch path only fills an EMPTY current date; an existing end_date the caller isn't touching is preserved. Tests added for each fix. Claude-Session: https://claude.ai/code/session_019knGmnHcx5rrgWXQ8V8DZS
928 lines
34 KiB
Go
928 lines
34 KiB
Go
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// --- resolveAssignName ---
|
|
|
|
func TestResolveAssignName_PassesThroughWhenMissing(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{Handler: errorHandler(t, "must not call members"), UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
|
out, err := d.resolveAssignName(context.Background(), &models.User{ID: "u"}, map[string]any{
|
|
"workspace": "ws", "title": "x",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if _, has := out["assign"]; has {
|
|
t.Errorf("output should not introduce an assign key; got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_PassesThroughWhenAssignEmpty(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{Handler: errorHandler(t, "must not call members"), UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
|
out, err := d.resolveAssignName(context.Background(), &models.User{ID: "u"}, map[string]any{
|
|
"workspace": "ws", "assign": "",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if v, _ := out["assigned_user_id"].(string); v != "" {
|
|
t.Errorf("expected no resolution for empty assign; got %v", out)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_ResolvesByName(t *testing.T) {
|
|
h := membersHandler(t, []memberRow{
|
|
{UserID: "u1", UserName: "Dave", UserEmail: "dave@example.com"},
|
|
{UserID: "u2", UserName: "Alice", UserEmail: "alice@example.com"},
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: h, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
|
|
out, err := d.resolveAssignName(context.Background(), &models.User{ID: "caller"}, map[string]any{
|
|
"workspace": "docapp", "assign": "Dave",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if out["assigned_user_id"] != "u1" {
|
|
t.Errorf("expected u1, got %v", out["assigned_user_id"])
|
|
}
|
|
if _, present := out["assign"]; present {
|
|
t.Errorf("assign key should be removed after resolution: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_ResolvesByEmailCaseInsensitive(t *testing.T) {
|
|
h := membersHandler(t, []memberRow{
|
|
{UserID: "u1", UserName: "Dave", UserEmail: "dave@example.com"},
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: h, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
|
|
out, err := d.resolveAssignName(context.Background(), &models.User{ID: "caller"}, map[string]any{
|
|
"workspace": "docapp", "assign": "DAVE@example.com",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if out["assigned_user_id"] != "u1" {
|
|
t.Errorf("expected u1 from case-insensitive email match, got %v", out["assigned_user_id"])
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_ErrorsWhenNoMatch(t *testing.T) {
|
|
h := membersHandler(t, []memberRow{{UserID: "u1", UserName: "Alice", UserEmail: "alice@example.com"}})
|
|
d := &HTTPHandlerDispatcher{Handler: h, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
|
|
_, err := d.resolveAssignName(context.Background(), &models.User{ID: "caller"}, map[string]any{
|
|
"workspace": "docapp", "assign": "Bob",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error for unmatched assignee")
|
|
}
|
|
if !strings.Contains(err.Error(), `"Bob"`) {
|
|
t.Errorf("error should mention the unmatched name; got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_ExplicitIDWins(t *testing.T) {
|
|
// If the caller passes both --assign Dave and an explicit
|
|
// assigned_user_id, the explicit ID wins and we skip the
|
|
// members-lookup entirely.
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: errorHandler(t, "must not call members when assigned_user_id is explicit"),
|
|
UserResolver: fixedUserResolver(&models.User{ID: "caller"}),
|
|
}
|
|
out, err := d.resolveAssignName(context.Background(), &models.User{ID: "caller"}, map[string]any{
|
|
"workspace": "docapp", "assign": "Dave", "assigned_user_id": "explicit-uuid",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if out["assigned_user_id"] != "explicit-uuid" {
|
|
t.Errorf("expected explicit ID to win; got %v", out)
|
|
}
|
|
if _, present := out["assign"]; present {
|
|
t.Errorf("assign key should be cleared even when ID-only path runs: %v", out)
|
|
}
|
|
}
|
|
|
|
func TestResolveAssignName_ErrorsWhenMembersEndpointFails(t *testing.T) {
|
|
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: h, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
|
|
_, err := d.resolveAssignName(context.Background(), &models.User{ID: "caller"}, map[string]any{
|
|
"workspace": "docapp", "assign": "Dave",
|
|
})
|
|
if err == nil {
|
|
t.Fatalf("expected error when members endpoint returns 403")
|
|
}
|
|
if !strings.Contains(err.Error(), "403") {
|
|
t.Errorf("error should propagate status: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- Dispatch preprocess for assign ---
|
|
|
|
func TestDispatch_PreprocessesAssignForItemCreate(t *testing.T) {
|
|
// End-to-end through Dispatch: input has --assign Dave; Dispatch
|
|
// resolves via members endpoint, mapItemCreate sees the resolved
|
|
// assigned_user_id, and the create POST carries the UUID.
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/members", membersHandler(t, []memberRow{
|
|
{UserID: "u-dave", UserName: "Dave", UserEmail: "dave@example.com"},
|
|
}).ServeHTTP)
|
|
mux.Handle("/api/v1/workspaces/docapp/collections/tasks/items", captured)
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"collection": "tasks",
|
|
"title": "Fix oauth",
|
|
"assign": "Dave",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "create"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
if captured.requestCount != 1 {
|
|
t.Fatalf("expected exactly 1 captured create request, got %d", captured.requestCount)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v\n%s", err, captured.lastBody)
|
|
}
|
|
if body["assigned_user_id"] != "u-dave" {
|
|
t.Errorf("create body did not carry resolved user id: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PreprocessAssignFailureSurfacesAsToolError(t *testing.T) {
|
|
// When resolution fails (no matching member), Dispatch must
|
|
// return an IsError result rather than dispatching the create
|
|
// with an empty assignee — silently posting without the
|
|
// assignment would be the worst outcome.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/members", membersHandler(t, []memberRow{
|
|
{UserID: "u-alice", UserName: "Alice", UserEmail: "alice@example.com"},
|
|
}).ServeHTTP)
|
|
createCount := 0
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/collections/tasks/items", func(_ http.ResponseWriter, _ *http.Request) {
|
|
createCount++
|
|
})
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "collection": "tasks", "title": "x",
|
|
"assign": "Bob",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "create"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError when assignee resolution fails; got %#v", res)
|
|
}
|
|
if createCount != 0 {
|
|
t.Errorf("create handler must not run after preprocess failure; ran %d times", createCount)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PreprocessSkippedForCommandsNotInAllowlist(t *testing.T) {
|
|
// `item show` doesn't take --assign — Dispatch must not call
|
|
// the members endpoint just because input happens to carry an
|
|
// `assign` key (e.g. from a stale schema cache).
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/members", func(_ http.ResponseWriter, _ *http.Request) {
|
|
t.Errorf("members endpoint must not be called for item.show")
|
|
})
|
|
mux.Handle("/api/v1/workspaces/docapp/items/TASK-5", captured)
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "ref": "TASK-5",
|
|
"assign": "Dave", // ignored
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "show"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
}
|
|
|
|
// --- dispatchItemUpdate ---
|
|
|
|
// TestDispatchItemUpdate_SendsFieldsPatch is the TASK-2022 update of the old
|
|
// read-modify-write test. The HTTP dispatcher no longer merges the existing
|
|
// fields client-side and sends a full `fields` blob — that lost concurrent
|
|
// single-field changes (IDEA-1480). It now sends ONLY the changed keys as
|
|
// `fields_patch`, and the SERVER merges under the write lock. So the PATCH
|
|
// body must carry `fields_patch` with just the keys this call changed
|
|
// (status + effort), and must NOT carry a full `fields` string or the
|
|
// untouched existing keys (priority/category).
|
|
func TestDispatchItemUpdate_SendsFieldsPatch(t *testing.T) {
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
// Existing item with two fields set — the dispatcher must NOT
|
|
// fold these into the PATCH anymore (server-side merge does).
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{
|
|
"ref":"TASK-5",
|
|
"fields":"{\"status\":\"open\",\"priority\":\"medium\",\"category\":\"infra\"}"
|
|
}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","status":"updated"}`))
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
})
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"ref": "TASK-5",
|
|
"status": "in-progress", // the only status change
|
|
"comment": "Started work", // top-level update
|
|
"field": []any{"effort=l"}, // adds a new key
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
if captured.requestCount != 1 {
|
|
t.Fatalf("expected 1 PATCH, got %d", captured.requestCount)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v\n%s", err, captured.lastBody)
|
|
}
|
|
if body["comment"] != "Started work" {
|
|
t.Errorf("comment lost: %v", body)
|
|
}
|
|
if _, present := body["fields"]; present {
|
|
t.Errorf("dispatcher must NOT send a full fields blob anymore; got %v", body)
|
|
}
|
|
patch, ok := body["fields_patch"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("fields_patch not an object in body: %v", body)
|
|
}
|
|
want := map[string]string{
|
|
"status": "in-progress",
|
|
"effort": "l", // newly added via --field
|
|
}
|
|
if len(patch) != len(want) {
|
|
t.Errorf("fields_patch should carry ONLY changed keys; got %v", patch)
|
|
}
|
|
for k, v := range want {
|
|
if got := patch[k]; got != v {
|
|
t.Errorf("fields_patch[%q] = %v, want %v", k, got, v)
|
|
}
|
|
}
|
|
// Untouched existing keys must NOT be echoed back into the patch.
|
|
if _, present := patch["priority"]; present {
|
|
t.Errorf("priority should not appear in a status-only patch: %v", patch)
|
|
}
|
|
}
|
|
|
|
// TestDispatchItemUpdate_TagsForwarding covers two Codex review #547
|
|
// rounds on the same area:
|
|
// - round 1 [P1]: the dispatcher must forward `tags: ["a"]` array
|
|
// shapes (was filtering on string-only, silently dropped them).
|
|
// - round 3 [P2]: forwarding ANY non-nil value regressed the
|
|
// "empty string is a no-op" behaviour; `tags: ""` would now write
|
|
// an empty string into the JSONB column (500 on Postgres). The
|
|
// fix forwards arrays unconditionally but skips empty strings.
|
|
//
|
|
// Subtests cover: array (the BUG-1432 happy path), empty array
|
|
// (legitimate "clear tags"), empty string (no-op — must not appear in
|
|
// the PATCH body), and nil (also no-op).
|
|
func TestDispatchItemUpdate_TagsForwarding(t *testing.T) {
|
|
type expect struct {
|
|
inBody bool
|
|
wantBody any
|
|
}
|
|
cases := []struct {
|
|
name string
|
|
tags any
|
|
want expect
|
|
}{
|
|
{"array of tags forwarded", []any{"v1", "frontend"}, expect{inBody: true, wantBody: []any{"v1", "frontend"}}},
|
|
{"empty array forwarded (clear tags)", []any{}, expect{inBody: true, wantBody: []any{}}},
|
|
{"empty string is a no-op", "", expect{inBody: false}},
|
|
{"comma-separated string forwarded (back-compat)", "foo,bar", expect{inBody: true, wantBody: "foo,bar"}},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{}"}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
})
|
|
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
// Always include title to force a PATCH (so the empty-string
|
|
// tags case still hits the dispatcher rather than no-op'ing
|
|
// the whole call for absence of changes).
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"ref": "TASK-5",
|
|
"title": "x",
|
|
"tags": tc.tags,
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
if captured.requestCount != 1 {
|
|
t.Fatalf("expected 1 PATCH, got %d", captured.requestCount)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v\n%s", err, captured.lastBody)
|
|
}
|
|
got, present := body["tags"]
|
|
if tc.want.inBody {
|
|
if !present {
|
|
t.Fatalf("expected tags in PATCH body, got body=%v", body)
|
|
}
|
|
// Compare via JSON round-trip so deep slices match.
|
|
gotJSON, _ := json.Marshal(got)
|
|
wantJSON, _ := json.Marshal(tc.want.wantBody)
|
|
if string(gotJSON) != string(wantJSON) {
|
|
t.Errorf("tags round-trip: got %s, want %s", gotJSON, wantJSON)
|
|
}
|
|
} else if present {
|
|
t.Errorf("expected tags ABSENT from PATCH body (no-op), got %v", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_NoFieldChangesSkipsFieldsMerge(t *testing.T) {
|
|
// Updating only top-level keys (title / content / comment)
|
|
// without any field-level changes must not include `fields` or
|
|
// `fields_patch` in the PATCH body. Sending a fields object would
|
|
// still go through the handler's schema validator (cheap but
|
|
// unnecessary), and a no-op update would churn the audit log.
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{\"status\":\"open\"}"}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
}
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "ref": "TASK-5",
|
|
"comment": "noted",
|
|
})
|
|
if _, err := d.Dispatch(ctx, []string{"item", "update"}, nil); err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if _, present := body["fields"]; present {
|
|
t.Errorf("fields should be omitted when no field changes; got %v", body)
|
|
}
|
|
if _, present := body["fields_patch"]; present {
|
|
t.Errorf("fields_patch should be omitted when no field changes; got %v", body)
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_SurfacesPrefetch404(t *testing.T) {
|
|
// If the GET prefetch fails (item not found), the PATCH must
|
|
// not run. The 404 surfaces to the agent as a tool error.
|
|
patchCount := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPatch {
|
|
patchCount++
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"item not found"}`))
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "ref": "TASK-5", "status": "done",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError result on 404 prefetch; got %#v", res)
|
|
}
|
|
if patchCount != 0 {
|
|
t.Errorf("PATCH must not run after 404 prefetch; ran %d times", patchCount)
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_RequiresWorkspaceAndRef(t *testing.T) {
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: errorHandler(t, "must not reach handler when workspace/ref missing"),
|
|
UserResolver: fixedUserResolver(&models.User{ID: "caller"}),
|
|
}
|
|
for _, missing := range []string{"workspace", "ref"} {
|
|
t.Run("missing-"+missing, func(t *testing.T) {
|
|
input := map[string]any{"workspace": "ws", "ref": "TASK-1", "status": "done"}
|
|
delete(input, missing)
|
|
ctx := WithDispatchInput(context.Background(), input)
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError when %s missing; got %#v", missing, res)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- Integration smoke against the real server ---
|
|
|
|
// TestHTTPHandlerDispatcher_Integration_ItemUpdateAndAssignResolution
|
|
// drives item.create → item.update through the real *server.Server,
|
|
// asserting that:
|
|
//
|
|
// - --assign Dave gets resolved via workspace.members → user UUID,
|
|
// and the item lands assigned to Dave.
|
|
// - item.update preserves existing fields while updating status —
|
|
// i.e. the read-modify-write merge actually keeps the priority +
|
|
// category set at create time.
|
|
//
|
|
// This is the behavioural integration the unit tests can't cover —
|
|
// they stub out the handler. Here we run against the real chi
|
|
// router + SQLite store so a regression in handler shape, store
|
|
// schema, or middleware ordering would fail this test.
|
|
func TestHTTPHandlerDispatcher_Integration_ItemUpdateAndAssignResolution(t *testing.T) {
|
|
srv, st := newPadServer(t)
|
|
|
|
// Bootstrap workspace + two users + memberships.
|
|
wsRec := doJSONReq(t, srv, http.MethodPost, "/api/v1/workspaces",
|
|
map[string]any{"name": "DocApp"})
|
|
if wsRec.Code != http.StatusCreated {
|
|
t.Fatalf("create workspace: %d %s", wsRec.Code, wsRec.Body.String())
|
|
}
|
|
var ws models.Workspace
|
|
if err := json.Unmarshal(wsRec.Body.Bytes(), &ws); err != nil {
|
|
t.Fatalf("decode workspace: %v", err)
|
|
}
|
|
|
|
owner, err := st.CreateUser(models.UserCreate{Email: "dave@example.com", Name: "Dave", Password: "x"})
|
|
if err != nil {
|
|
t.Fatalf("create owner: %v", err)
|
|
}
|
|
assignee, err := st.CreateUser(models.UserCreate{Email: "alice@example.com", Name: "Alice", Password: "x"})
|
|
if err != nil {
|
|
t.Fatalf("create assignee: %v", err)
|
|
}
|
|
if err := st.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
|
|
t.Fatalf("add owner: %v", err)
|
|
}
|
|
if err := st.AddWorkspaceMember(ws.ID, assignee.ID, "editor"); err != nil {
|
|
t.Fatalf("add editor: %v", err)
|
|
}
|
|
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: srv,
|
|
UserResolver: fixedUserResolver(owner),
|
|
}
|
|
|
|
// Create an item with --assign Alice — exercises Dispatch's
|
|
// preprocess + the members-lookup against the real handler.
|
|
createCtx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": ws.Slug,
|
|
"collection": "tasks",
|
|
"title": "Smoke",
|
|
"priority": "high",
|
|
"category": "infra",
|
|
"assign": "Alice",
|
|
})
|
|
createRes, err := d.Dispatch(createCtx, []string{"item", "create"}, nil)
|
|
if err != nil || createRes.IsError {
|
|
t.Fatalf("item create: err=%v IsError=%v: %#v", err, createRes != nil && createRes.IsError, createRes)
|
|
}
|
|
created, ok := createRes.StructuredContent.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("create result not structured: %#v", createRes.StructuredContent)
|
|
}
|
|
ref, _ := created["ref"].(string)
|
|
if ref == "" {
|
|
t.Fatalf("created item missing ref: %#v", created)
|
|
}
|
|
if got, _ := created["assigned_user_id"].(string); got != assignee.ID {
|
|
t.Errorf("created item not assigned to Alice; got %q want %q", got, assignee.ID)
|
|
}
|
|
|
|
// Update only the status. The priority + category set at create
|
|
// time MUST survive — that's what the read-modify-write merge
|
|
// guarantees vs. the handler treating Fields as a complete
|
|
// replacement.
|
|
updateCtx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": ws.Slug,
|
|
"ref": ref,
|
|
"status": "in-progress",
|
|
"comment": "Picked up.",
|
|
})
|
|
updateRes, err := d.Dispatch(updateCtx, []string{"item", "update"}, nil)
|
|
if err != nil || updateRes.IsError {
|
|
t.Fatalf("item update: err=%v IsError=%v: %#v", err, updateRes != nil && updateRes.IsError, updateRes)
|
|
}
|
|
updated, ok := updateRes.StructuredContent.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("update result not structured: %#v", updateRes.StructuredContent)
|
|
}
|
|
// BUG-991 path A: `fields` is now parsed at the MCP boundary so
|
|
// it arrives as a native map. Pre-fix we had to JSON.Unmarshal
|
|
// the string ourselves here.
|
|
fieldMap := itemFieldsAsMap(t, updated)
|
|
want := map[string]string{
|
|
"status": "in-progress",
|
|
"priority": "high", // pre-existing, must survive RMW
|
|
"category": "infra", // pre-existing, must survive RMW
|
|
}
|
|
for k, v := range want {
|
|
if got, _ := fieldMap[k].(string); got != v {
|
|
t.Errorf("fields[%q] = %q, want %q (full fields: %v)", k, got, v, fieldMap)
|
|
}
|
|
}
|
|
}
|
|
|
|
// itemFieldsAsMap reads the parsed `fields` value off an item-shape
|
|
// structuredContent map. After BUG-991 path A's normalization the
|
|
// MCP boundary returns `fields` as a native map[string]any; tests
|
|
// that previously decoded a string fall through this helper for
|
|
// readability + a clear error when the shape regresses.
|
|
func itemFieldsAsMap(t *testing.T, item map[string]any) map[string]any {
|
|
t.Helper()
|
|
v, ok := item["fields"]
|
|
if !ok {
|
|
t.Fatalf("item missing `fields`: %#v", item)
|
|
}
|
|
m, ok := v.(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("item.fields is %T, want map[string]any (BUG-991 normalization regressed?)", v)
|
|
}
|
|
return m
|
|
}
|
|
|
|
func TestDispatchItemUpdate_LiftsAgentRoleIDFromFieldKVPToColumn(t *testing.T) {
|
|
// Same reachability fix as mapItemCreate: agents passing
|
|
// `--field agent_role_id=<uuid>` (the only schema-visible path
|
|
// for setting the column today) get the value lifted onto
|
|
// the PATCH body's top-level. Otherwise the value would sit
|
|
// inert inside the merged fields JSON and the role assignment
|
|
// would no-op (Codex review #345 round 3).
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{\"status\":\"open\"}"}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
}
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"ref": "TASK-5",
|
|
"field": []any{"agent_role_id=role-uuid-from-field"},
|
|
})
|
|
if _, err := d.Dispatch(ctx, []string{"item", "update"}, nil); err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["agent_role_id"] != "role-uuid-from-field" {
|
|
t.Errorf("agent_role_id not lifted onto PATCH body: %v", body)
|
|
}
|
|
// And not in the fields blob.
|
|
fields := map[string]any{}
|
|
if s, _ := body["fields"].(string); s != "" {
|
|
_ = json.Unmarshal([]byte(s), &fields)
|
|
}
|
|
if _, present := fields["agent_role_id"]; present {
|
|
t.Errorf("agent_role_id should be removed from fields blob: %v", fields)
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_PassesThroughAgentRoleID(t *testing.T) {
|
|
// Parity with mapItemCreate: agent_role_id (UUID) writes to the
|
|
// ItemUpdate column. The --role rejection points agents at this
|
|
// path, so it must actually work end-to-end (Codex review #345
|
|
// round 2 caught the misleading error pointing at --field, which
|
|
// would have put the value in the fields JSON instead).
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{}"}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
}
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"ref": "TASK-5",
|
|
"agent_role_id": "role-uuid-789",
|
|
})
|
|
if _, err := d.Dispatch(ctx, []string{"item", "update"}, nil); err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["agent_role_id"] != "role-uuid-789" {
|
|
t.Errorf("agent_role_id not in PATCH body: %v", body)
|
|
}
|
|
// And NOT inside the fields JSON (the misleading workaround
|
|
// would have put it there).
|
|
if fields, _ := body["fields"].(string); fields != "" {
|
|
t.Errorf("agent_role_id should not be in fields blob: %v", fields)
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_ResolvesRoleSlugToAgentRoleID(t *testing.T) {
|
|
// TASK-968 replaced the older --role rejection with a Dispatch-
|
|
// level preprocess that resolves slug → agent_role_id via the
|
|
// /agent-roles/{slug} endpoint. The PATCH body should carry the
|
|
// resolved UUID in `agent_role_id` and not in `fields`, AND the
|
|
// `role` key should be gone from the input by the time the
|
|
// handler sees it.
|
|
captured := newRequestCapture()
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/agent-roles/implementer", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"id":"role-uuid-imp","slug":"implementer","name":"Implementer"}`))
|
|
})
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{\"status\":\"open\"}"}`))
|
|
case http.MethodPatch:
|
|
captured.ServeHTTP(w, r)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
default:
|
|
t.Fatalf("unexpected method %s", r.Method)
|
|
}
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp",
|
|
"ref": "TASK-5",
|
|
"role": "implementer",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
var body map[string]any
|
|
if err := json.Unmarshal([]byte(captured.lastBody), &body); err != nil {
|
|
t.Fatalf("decode body: %v", err)
|
|
}
|
|
if body["agent_role_id"] != "role-uuid-imp" {
|
|
t.Errorf("expected agent_role_id from slug resolution; got %v", body)
|
|
}
|
|
// And NOT inside fields blob.
|
|
fields := map[string]any{}
|
|
if s, _ := body["fields"].(string); s != "" {
|
|
_ = json.Unmarshal([]byte(s), &fields)
|
|
}
|
|
if _, present := fields["agent_role_id"]; present {
|
|
t.Errorf("agent_role_id should not be in fields blob: %v", fields)
|
|
}
|
|
if _, present := body["role"]; present {
|
|
t.Errorf("`role` key should be removed after resolution: %v", body)
|
|
}
|
|
}
|
|
|
|
func TestDispatchItemUpdate_RoleResolutionFailureSurfacesAsToolError(t *testing.T) {
|
|
// Slug-not-found at the agent-roles endpoint must abort the
|
|
// dispatch — the older rejection guarded against silent drops
|
|
// of the role assignment, and the new preprocess preserves that
|
|
// guarantee on a different code path.
|
|
patchCount := 0
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/agent-roles/ghost", func(w http.ResponseWriter, _ *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
_, _ = w.Write([]byte(`{"error":"not found"}`))
|
|
})
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(_ http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPatch {
|
|
patchCount++
|
|
}
|
|
})
|
|
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "caller"})}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "ref": "TASK-5",
|
|
"role": "ghost",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil {
|
|
t.Fatalf("Dispatch err: %v", err)
|
|
}
|
|
if !res.IsError {
|
|
t.Errorf("expected IsError when role resolution fails; got %#v", res)
|
|
}
|
|
if patchCount != 0 {
|
|
t.Errorf("PATCH must not run after role resolution failure; ran %d times", patchCount)
|
|
}
|
|
}
|
|
|
|
func TestDispatch_PrefetchesGoThroughApplyHook(t *testing.T) {
|
|
// Codex review #345 round 1: in-handler prefetches (members
|
|
// lookup for --assign, item.update's GET) used to bypass d.Apply,
|
|
// which would have meant any OAuth scope-context attached at
|
|
// dispatch time wouldn't apply to those side-channel reads —
|
|
// possible scope bypass when the future TASK-953 middleware
|
|
// wires Apply to attach token-allow-list context.
|
|
//
|
|
// This test asserts EVERY synthesized request (preprocess
|
|
// members lookup + item.update prefetch + main PATCH) flows
|
|
// through the Apply callback.
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/members", membersHandler(t, []memberRow{
|
|
{UserID: "u-dave", UserName: "Dave"},
|
|
}).ServeHTTP)
|
|
mux.HandleFunc("/api/v1/workspaces/docapp/items/TASK-5", func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.Method {
|
|
case http.MethodGet:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5","fields":"{\"status\":\"open\"}"}`))
|
|
case http.MethodPatch:
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"ref":"TASK-5"}`))
|
|
}
|
|
})
|
|
|
|
const marker = "X-Test-Apply-Marker"
|
|
applyCalls := []string{}
|
|
d := &HTTPHandlerDispatcher{
|
|
Handler: mux,
|
|
UserResolver: fixedUserResolver(&models.User{ID: "caller"}),
|
|
Apply: func(r *http.Request) *http.Request {
|
|
applyCalls = append(applyCalls, r.Method+" "+r.URL.Path)
|
|
r.Header.Set(marker, "yes")
|
|
return r
|
|
},
|
|
}
|
|
ctx := WithDispatchInput(context.Background(), map[string]any{
|
|
"workspace": "docapp", "ref": "TASK-5",
|
|
"status": "in-progress",
|
|
"assign": "Dave",
|
|
})
|
|
res, err := d.Dispatch(ctx, []string{"item", "update"}, nil)
|
|
if err != nil || res.IsError {
|
|
t.Fatalf("Dispatch err=%v IsError=%v: %#v", err, res != nil && res.IsError, res)
|
|
}
|
|
|
|
// Three requests synthesized: members lookup (preprocess) +
|
|
// item GET (prefetch) + item PATCH (main). All must have flowed
|
|
// through Apply.
|
|
wantPaths := map[string]bool{
|
|
"GET /api/v1/workspaces/docapp/members": false,
|
|
"GET /api/v1/workspaces/docapp/items/TASK-5": false,
|
|
"PATCH /api/v1/workspaces/docapp/items/TASK-5": false,
|
|
}
|
|
for _, call := range applyCalls {
|
|
if _, expected := wantPaths[call]; expected {
|
|
wantPaths[call] = true
|
|
}
|
|
}
|
|
for path, seen := range wantPaths {
|
|
if !seen {
|
|
t.Errorf("Apply did not see %q (saw %v)", path, applyCalls)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRouteTable_ContainsWorkspaceMembers(t *testing.T) {
|
|
if _, ok := routeTable["workspace members"]; !ok {
|
|
t.Errorf("routeTable should include `workspace members` after TASK-967")
|
|
}
|
|
m, p, _, err := routeTable["workspace members"](map[string]any{"workspace": "docapp"})
|
|
if err != nil {
|
|
t.Fatalf("err: %v", err)
|
|
}
|
|
if m != http.MethodGet {
|
|
t.Errorf("method = %q", m)
|
|
}
|
|
if p != "/api/v1/workspaces/docapp/members" {
|
|
t.Errorf("path = %q", p)
|
|
}
|
|
}
|
|
|
|
// --- Test fixtures ---
|
|
|
|
// memberRow is the test-side mirror of the (subset of) fields the
|
|
// resolveAssignName lookup reads from the workspace-members response.
|
|
type memberRow struct {
|
|
UserID string `json:"user_id"`
|
|
UserName string `json:"user_name"`
|
|
UserEmail string `json:"user_email"`
|
|
}
|
|
|
|
// membersHandler returns an http.Handler that responds to any path
|
|
// with the standard `{members:[...], invitations:[]}` shape using
|
|
// the supplied rows.
|
|
func membersHandler(t *testing.T, rows []memberRow) http.Handler {
|
|
t.Helper()
|
|
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
body := map[string]any{
|
|
"members": rows,
|
|
"invitations": []any{},
|
|
}
|
|
_ = json.NewEncoder(w).Encode(body)
|
|
})
|
|
}
|
|
|
|
// errorHandler fails the test if invoked. Used to assert that a
|
|
// code path doesn't hit the handler at all.
|
|
func errorHandler(t *testing.T, msg string) http.Handler {
|
|
t.Helper()
|
|
return http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
|
|
t.Errorf("unexpected handler call: %s", msg)
|
|
})
|
|
}
|
|
|
|
// requestCapture is a tiny helper that records the last request +
|
|
// counts how many times it was called. Compatible with http.Handler
|
|
// so it slots into mux.Handle / mux.HandleFunc directly.
|
|
type requestCapture struct {
|
|
requestCount int
|
|
lastMethod string
|
|
lastPath string
|
|
lastBody string
|
|
}
|
|
|
|
func newRequestCapture() *requestCapture { return &requestCapture{} }
|
|
|
|
func (c *requestCapture) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
c.requestCount++
|
|
c.lastMethod = r.Method
|
|
c.lastPath = r.URL.Path
|
|
if r.Body != nil {
|
|
body, _ := io.ReadAll(r.Body)
|
|
c.lastBody = string(body)
|
|
}
|
|
if w.Header().Get("Content-Type") == "" {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
if r.Method == http.MethodPost {
|
|
_, _ = w.Write([]byte(`{"ok":true}`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`{}`))
|
|
}
|