Files
multica/server/internal/handler/issue_create_labels_test.go
Bohan Jiang 4fcb27a729 fix(issues): attach labels in the create transaction (MUL-4832) (#5510)
* fix(issues): attach labels in the create transaction (MUL-4832)

Labels chosen at issue creation were attached in a second, non-atomic
round-trip after the issue was already committed (web modal looped
attachLabelToIssue per label; the create endpoint took no labels). A
partial failure of that follow-up left the issue committed but
mis-categorized, surfaced only as a toast.

Carry label_ids through the create request instead: the service
validates them (workspace + resource_type='issue') and attaches them
inside the same transaction as the issue insert, so the issue and its
labels commit together or not at all. An unknown or wrong-scope label
id now fails the whole create with 400 rather than being silently
dropped. Duplicate ids are idempotent (dedupe + ON CONFLICT DO NOTHING).

- server: IssueCreateParams.LabelIDs + ErrIssueLabelNotFound; validate
  and attach in IssueService.Create; handler parses label_ids and maps
  the error to 400.
- web: create-issue modal forwards label_ids and drops the post-create
  attach loop and its dead toast key.
- tests: handler coverage for atomic attach, stale-id 400 (no issue
  left behind), duplicate-id idempotency, wrong-scope rejection; web
  test asserts label_ids is forwarded.

Scope is deliberately labels-only. The contributor PR #5475 also
auto-parsed an acceptance_criteria field from description text; that
introduces a new user-facing data contract with no defined edit/display
rules and is left out for a separate product decision.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): echo created labels + guard old-backend compat (MUL-4832)

Addresses review of #5510.

1. Create response + issue:created event now carry the labels attached in
   the create transaction. IssueService.Create returns the authoritative
   label snapshot; the handler sets it on both the HTTP response and the
   issue:created broadcast payload. Without this, online members other than
   the creator saw the new issue unlabeled indefinitely (staleTime: Infinity,
   no invalidation) because this PR removed the old post-create
   issue_labels:changed broadcast.

2. New web + old backend compatibility. During the rolling deploy window the
   web app can run ahead of the backend (web auto-deploys on merge, backend
   deploys manually). An older backend ignores label_ids and returns an issue
   with no labels field. The create modal now falls back to the legacy
   per-label attach only when the response omits labels, so labels aren't
   silently dropped; when labels is present the atomic path already ran and
   no fallback fires. The backend always returns an explicit labels array
   (empty when none) as the detection signal.

Tests: handler issue:created-carries-labels + response-carries-labels +
response-always-includes-labels; web modal no-fallback (new backend) and
fallback (old backend); ws-updaters keeps the label snapshot in list cache.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): validate create response through a schema (MUL-4832)

Addresses review of #5510 (must-fix 3).

The create modal keys its label-attach compatibility fallback off
`issue.labels` being absent (older backend) vs a validated Label[]
(current backend). But api.createIssue cast the raw JSON straight to
Issue, so a malformed labels value (null, an object, a garbage array)
would be !== undefined and wrongly suppress the fallback, caching a bad
shape — violating the repo's API Compatibility rule.

Parse the create response through CreateIssueResponseSchema:
- labels absent  -> undefined (older-backend signal; fallback runs)
- labels valid   -> Label[] (fully validated elements, not z.unknown())
- labels malformed -> undefined via .catch (safe: never masquerades as
  handled; worst case a redundant re-attach, never a silent drop)
A whole-body parse failure degrades to EMPTY_ISSUE (never throws into
React), matching the existing parseWithFallback contract.

Tests: schema.test.ts covers absent / valid / null / wrong-element-shape
labels and the empty-issue degrade.

Co-authored-by: multica-agent <github@multica.ai>

* fix(issues): reject a malformed create response instead of faking success (MUL-4832)

Follow-up to review of #5510. The prior fix degraded a schema-failed
create response to EMPTY_ISSUE, which React Query treats as a successful
mutation: the empty issue is written into the list cache, a blank
"created" toast shows, the "View issue" link points at an empty id, and
with labels the fallback attach runs against an empty issue id.

A create that returns an unusable body is a failed mutation, not a
safe-empty read. Fall back to null and reject: mutateAsync is already
inside the create modal's try/catch, so a controlled rejection preserves
the draft and shows the failure toast, and onSettled still refreshes the
list so a genuinely-created issue can still surface.

- CreateIssueResponseSchema tightens id to non-empty; an id-less body
  routes to the same reject path.
- createIssue throws on parse failure (empty-message Error so the modal
  renders its localized "failed to create" toast; parseWithFallback
  already logged the schema issues + raw body).
- Dropped the EMPTY_ISSUE fallback constant.
- Tests: whole-body malformed and empty-id now assert createIssue
  rejects; only-labels-malformed still returns the real issue with
  labels undefined.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Bohan-J <bohan@devv.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 13:31:06 +08:00

269 lines
9.1 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/google/uuid"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// createTestIssueLabel creates an issue-scoped label in the test workspace via
// the public handler and registers cleanup. It returns the new label id.
func createTestIssueLabel(t *testing.T, name string) string {
t.Helper()
w := httptest.NewRecorder()
req := newRequest("POST", "/api/labels", map[string]any{
"name": name,
"color": "#ef4444",
})
testHandler.CreateLabel(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateLabel %q: expected 201, got %d: %s", name, w.Code, w.Body.String())
}
var created LabelResponse
if err := json.NewDecoder(w.Body).Decode(&created); err != nil {
t.Fatalf("decode label: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_label WHERE id = $1`, created.ID)
})
return created.ID
}
func countIssueLabel(t *testing.T, issueID, labelID string) int {
t.Helper()
var n int
if err := testPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM issue_to_label WHERE issue_id = $1 AND label_id = $2`,
issueID, labelID,
).Scan(&n); err != nil {
t.Fatalf("count issue_to_label: %v", err)
}
return n
}
func countIssuesWithTitle(t *testing.T, title string) int {
t.Helper()
var n int
if err := testPool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM issue WHERE workspace_id = $1 AND title = $2`,
testWorkspaceID, title,
).Scan(&n); err != nil {
t.Fatalf("count issues: %v", err)
}
return n
}
// TestCreateIssueAttachesLabelsAtomically verifies that labels passed in the
// create request are written in the same transaction as the issue, so a
// created issue already carries its labels without a second round-trip.
func TestCreateIssueAttachesLabelsAtomically(t *testing.T) {
labelA := createTestIssueLabel(t, "cl-a-"+uuid.NewString()[:8])
labelB := createTestIssueLabel(t, "cl-b-"+uuid.NewString()[:8])
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "create-labels attaches",
"status": "todo",
"priority": "low",
"label_ids": []string{labelA, labelB},
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
if got := countIssueLabel(t, issue.ID, labelA); got != 1 {
t.Errorf("label A: expected 1 attachment, got %d", got)
}
if got := countIssueLabel(t, issue.ID, labelB); got != 1 {
t.Errorf("label B: expected 1 attachment, got %d", got)
}
// The create response must echo the authoritative labels so every online
// client (not just the creator) renders the new issue already labeled and
// a newer client can tell the backend understood label_ids.
if issue.Labels == nil {
t.Fatalf("create response must include a labels field")
}
got := map[string]bool{}
for _, l := range *issue.Labels {
got[l.ID] = true
}
if !got[labelA] || !got[labelB] {
t.Errorf("response labels %v missing labelA=%s or labelB=%s", got, labelA, labelB)
}
}
// TestCreateIssueResponseAlwaysIncludesLabelsField verifies that even when no
// labels are requested the create response carries an explicit (empty) labels
// list — the signal a newer client uses to know the backend handled labels and
// skip its legacy per-label attach fallback.
func TestCreateIssueResponseAlwaysIncludesLabelsField(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "create-labels no-label response",
"status": "todo",
"priority": "low",
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
if issue.Labels == nil {
t.Fatalf("create response must include a labels field even when empty")
}
if len(*issue.Labels) != 0 {
t.Errorf("expected empty labels, got %d", len(*issue.Labels))
}
}
// TestCreateIssueEventCarriesLabels verifies that the issue:created websocket
// event carries the attached labels, so online members other than the creator
// render the new issue already labeled instead of blank until a refetch (the
// old flow relied on a separate issue_labels:changed broadcast this PR removed).
func TestCreateIssueEventCarriesLabels(t *testing.T) {
label := createTestIssueLabel(t, "cl-evt-"+uuid.NewString()[:8])
gotEvent := make(chan events.Event, 1)
testHandler.Bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
select {
case gotEvent <- e:
default:
}
})
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "create-labels event carries",
"status": "todo",
"priority": "low",
"label_ids": []string{label},
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
select {
case ev := <-gotEvent:
pm, ok := ev.Payload.(map[string]any)
if !ok {
t.Fatalf("issue:created payload has unexpected type %T", ev.Payload)
}
resp, ok := pm["issue"].(IssueResponse)
if !ok {
t.Fatalf("issue:created payload issue has unexpected type %T", pm["issue"])
}
if resp.Labels == nil {
t.Fatalf("issue:created payload must include a labels field")
}
found := false
for _, l := range *resp.Labels {
if l.ID == label {
found = true
}
}
if !found {
t.Errorf("issue:created labels %+v missing %s", *resp.Labels, label)
}
case <-time.After(2 * time.Second):
t.Fatal("did not receive issue:created event")
}
}
// TestCreateIssueDedupesDuplicateLabelIDs verifies that a repeated label id in
// the request attaches exactly once (ON CONFLICT DO NOTHING) and still 201s.
func TestCreateIssueDedupesDuplicateLabelIDs(t *testing.T) {
label := createTestIssueLabel(t, "cl-dup-"+uuid.NewString()[:8])
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": "create-labels dedupe",
"status": "todo",
"priority": "low",
"label_ids": []string{label, label},
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusCreated {
t.Fatalf("CreateIssue: expected 201, got %d: %s", w.Code, w.Body.String())
}
var issue IssueResponse
json.NewDecoder(w.Body).Decode(&issue)
t.Cleanup(func() { deleteTestIssue(t, issue.ID) })
if got := countIssueLabel(t, issue.ID, label); got != 1 {
t.Errorf("expected label attached exactly once, got %d", got)
}
}
// TestCreateIssueRejectsUnknownLabelWithoutCreating verifies that an unknown
// label id fails the whole create with 400 and leaves no issue behind — the
// atomicity guarantee the old post-create attach flow could not offer.
func TestCreateIssueRejectsUnknownLabelWithoutCreating(t *testing.T) {
const title = "create-labels unknown rejected"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
"priority": "low",
"label_ids": []string{uuid.NewString()},
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("CreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
}
if got := countIssuesWithTitle(t, title); got != 0 {
t.Errorf("expected no issue created on invalid label, found %d", got)
}
}
// TestCreateIssueRejectsNonIssueScopedLabel verifies that a label scoped to a
// non-issue resource (e.g. agent) cannot be attached to an issue at create,
// mirroring the resource_type guard baked into AttachLabelToIssue.
func TestCreateIssueRejectsNonIssueScopedLabel(t *testing.T) {
var agentLabelID string
if err := testPool.QueryRow(context.Background(),
`INSERT INTO issue_label (workspace_id, resource_type, name, color)
VALUES ($1, 'agent', $2, '#000000') RETURNING id`,
testWorkspaceID, "create-labels agent "+uuid.NewString(),
).Scan(&agentLabelID); err != nil {
t.Fatalf("insert agent label: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_label WHERE id = $1`, agentLabelID)
})
const title = "create-labels wrong scope rejected"
w := httptest.NewRecorder()
req := newRequest("POST", "/api/issues?workspace_id="+testWorkspaceID, map[string]any{
"title": title,
"status": "todo",
"priority": "low",
"label_ids": []string{agentLabelID},
})
testHandler.CreateIssue(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("CreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
}
if got := countIssuesWithTitle(t, title); got != 0 {
t.Errorf("expected no issue created on wrong-scope label, found %d", got)
}
}