mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 11:48:42 +02:00
The `POST /api/issues/batch-update` handler walked every issue ID and
incremented `updated` regardless of whether the iteration carried any
mutation. When the caller's payload had no recognized field in
`updates` — e.g. status placed at the top level instead of nested,
"update" misspelled as singular, or "updates" missing entirely —
the loop ran N no-op UPDATEs (each if-guard skipped, each COALESCE
preserved the existing value) and the response cheerfully reported
`{"updated": N}` while nothing changed. Reporters mistook the
positive count for success and chased a phantom persistence bug.
Detect at the top of the handler whether any known mutation field is
present in the parsed `updates` payload; if none is, short-circuit
with `{"updated": 0}`. The wire shape stays 200 + `{updated}`
so existing callers don't break — only the count becomes truthful.
Tests cover the three caller shapes that hit this path (status at top
level, empty `updates: {}`, misspelled "update") plus a positive
case that locks in happy-path persistence and counting.
Closes #1660.
150 lines
5.0 KiB
Go
150 lines
5.0 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
// TestBatchUpdateNoMutationReturnsZero — regression for #1660.
|
|
//
|
|
// When the request payload has valid issue_ids but the "updates" field
|
|
// is empty, missing, or doesn't decode any known mutation field, the
|
|
// handler used to walk every issue, run a no-op UPDATE, and increment
|
|
// `updated` for each one — returning {"updated": N} despite changing
|
|
// nothing. Reporters saw 200 + a positive count and assumed the call
|
|
// worked, then chased a phantom persistence bug.
|
|
//
|
|
// The fix is "tell the truth": when no mutation field is present, return
|
|
// {"updated": 0} immediately so the count matches reality.
|
|
func TestBatchUpdateNoMutationReturnsZero(t *testing.T) {
|
|
// Two fresh issues so we can also assert no fields actually changed.
|
|
a := createTestIssue(t, "BU-no-mut A", "todo", "low")
|
|
b := createTestIssue(t, "BU-no-mut B", "todo", "low")
|
|
t.Cleanup(func() { deleteTestIssue(t, a) })
|
|
t.Cleanup(func() { deleteTestIssue(t, b) })
|
|
|
|
cases := []struct {
|
|
desc string
|
|
body map[string]any
|
|
}{
|
|
{
|
|
desc: "updates_missing",
|
|
// Most common reporter pattern: status at top level.
|
|
body: map[string]any{"issue_ids": []string{a, b}, "status": "in_progress"},
|
|
},
|
|
{
|
|
desc: "updates_empty_object",
|
|
body: map[string]any{"issue_ids": []string{a, b}, "updates": map[string]any{}},
|
|
},
|
|
{
|
|
desc: "updates_misnamed",
|
|
// Singular "update" instead of plural "updates".
|
|
body: map[string]any{"issue_ids": []string{a, b}, "update": map[string]any{"status": "done"}},
|
|
},
|
|
{
|
|
desc: "updates_unknown_field_only",
|
|
// Payload IS nested correctly, but every key inside `updates` is
|
|
// unknown to the handler — same class of caller mistake as the
|
|
// shapes above. hasMutation must stay false; behavior is already
|
|
// correct, this case locks it in against future regressions.
|
|
body: map[string]any{"issue_ids": []string{a, b}, "updates": map[string]any{"foo": "bar"}},
|
|
},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.desc, func(t *testing.T) {
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/batch-update", tc.body)
|
|
testHandler.BatchUpdateIssues(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Updated int `json:"updated"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp.Updated != 0 {
|
|
t.Errorf("expected updated=0 when no mutation field present, got %d", resp.Updated)
|
|
}
|
|
|
|
// Belt and braces: confirm the issues weren't touched.
|
|
for _, id := range []string{a, b} {
|
|
gw := httptest.NewRecorder()
|
|
gr := newRequest("GET", "/api/issues/"+id, nil)
|
|
gr = withURLParam(gr, "id", id)
|
|
testHandler.GetIssue(gw, gr)
|
|
var got IssueResponse
|
|
json.NewDecoder(gw.Body).Decode(&got)
|
|
if got.Status != "todo" {
|
|
t.Errorf("issue %s: status changed to %q despite no-mutation request", id, got.Status)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBatchUpdateValidUpdatesPersistAndCount — positive case to lock in
|
|
// happy-path behavior alongside the regression test above.
|
|
func TestBatchUpdateValidUpdatesPersistAndCount(t *testing.T) {
|
|
a := createTestIssue(t, "BU-ok A", "todo", "low")
|
|
b := createTestIssue(t, "BU-ok B", "todo", "low")
|
|
t.Cleanup(func() { deleteTestIssue(t, a) })
|
|
t.Cleanup(func() { deleteTestIssue(t, b) })
|
|
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues/batch-update", map[string]any{
|
|
"issue_ids": []string{a, b},
|
|
"updates": map[string]any{"status": "in_progress"},
|
|
})
|
|
testHandler.BatchUpdateIssues(w, req)
|
|
if w.Code != http.StatusOK {
|
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
var resp struct {
|
|
Updated int `json:"updated"`
|
|
}
|
|
json.NewDecoder(w.Body).Decode(&resp)
|
|
if resp.Updated != 2 {
|
|
t.Errorf("expected updated=2, got %d", resp.Updated)
|
|
}
|
|
for _, id := range []string{a, b} {
|
|
gw := httptest.NewRecorder()
|
|
gr := newRequest("GET", "/api/issues/"+id, nil)
|
|
gr = withURLParam(gr, "id", id)
|
|
testHandler.GetIssue(gw, gr)
|
|
var got IssueResponse
|
|
json.NewDecoder(gw.Body).Decode(&got)
|
|
if got.Status != "in_progress" {
|
|
t.Errorf("issue %s: expected status=in_progress, got %q", id, got.Status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// createTestIssue is a small helper to keep the table-driven cases clean.
|
|
// Returns the new issue's id; caller is responsible for cleanup.
|
|
func createTestIssue(t *testing.T, title, status, priority string) string {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
|
|
"title": title,
|
|
"status": status,
|
|
"priority": priority,
|
|
})
|
|
testHandler.CreateIssue(w, req)
|
|
if w.Code != http.StatusCreated {
|
|
t.Fatalf("CreateIssue %q: expected 201, got %d: %s", title, w.Code, w.Body.String())
|
|
}
|
|
var issue IssueResponse
|
|
json.NewDecoder(w.Body).Decode(&issue)
|
|
return issue.ID
|
|
}
|
|
|
|
func deleteTestIssue(t *testing.T, id string) {
|
|
t.Helper()
|
|
w := httptest.NewRecorder()
|
|
req := newRequest("DELETE", "/api/issues/"+id, nil)
|
|
req = withURLParam(req, "id", id)
|
|
testHandler.DeleteIssue(w, req)
|
|
}
|