Files
multica/server/internal/handler/issue_status_test.go
T
db86c75e12 MUL-6878: avoid per-row issue status resolution (#7814)
* perf(server): avoid per-row issue status resolution

Co-authored-by: multica-agent <[email protected]>

* fix(server): address issue status query review nits

Co-authored-by: multica-agent <[email protected]>

---------

Co-authored-by: Sol-Boy <[email protected]>
Co-authored-by: multica-agent <[email protected]>
2026-08-31 16:00:45 +08:00

1837 lines
70 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/events"
"github.com/multica-ai/multica/server/internal/issuestatus"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/testutil"
db "github.com/multica-ai/multica/server/pkg/db/generated"
"github.com/multica-ai/multica/server/pkg/protocol"
)
// Issue status catalog tests (MUL-6243).
// seedTestCatalog makes the shared test workspace's catalog present. The
// fixture creates its workspace with raw SQL, so it has no catalog rows —
// which is itself the unseeded case covered by TestUnseededWorkspaceStillAccepts.
func seedTestCatalog(t *testing.T) {
t.Helper()
if err := issuestatus.Ensure(context.Background(), testHandler.Queries, parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("seed catalog: %v", err)
}
}
// createTestCustomStatus inserts a custom status directly and removes it after
// the test, so catalog state cannot leak between tests in the shared workspace.
func createTestCustomStatus(t *testing.T, key, category string) db.IssueStatus {
t.Helper()
seedTestCatalog(t)
entry, err := testHandler.Queries.CreateIssueStatusEntry(context.Background(), db.CreateIssueStatusEntryParams{
WorkspaceID: parseUUID(testWorkspaceID),
Key: key,
Name: key,
Description: "",
Category: category,
Color: "#123456",
})
if err != nil {
t.Fatalf("create custom status %q: %v", key, err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, entry.ID)
})
return entry
}
// TestEnsureIsIdempotent covers the rolling-deploy case: two pods can seed the
// same workspace concurrently and the second must be a no-op, not an error.
func TestEnsureIsIdempotent(t *testing.T) {
ctx := context.Background()
seedTestCatalog(t)
if err := issuestatus.Ensure(ctx, testHandler.Queries, parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("second Ensure should be a no-op, got: %v", err)
}
entries, err := testHandler.Queries.ListIssueStatusEntries(ctx, db.ListIssueStatusEntriesParams{
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("list catalog: %v", err)
}
systemByKey := map[string]db.IssueStatus{}
for _, e := range entries {
if e.IsSystem {
systemByKey[e.Key] = e
}
}
if len(systemByKey) != 7 {
t.Fatalf("expected exactly 7 built-in rows after double seeding, got %d", len(systemByKey))
}
// Every built-in must be its own category's canonical — the invariant that
// makes Effective an identity function on built-in keys.
for key, entry := range systemByKey {
if entry.Category != key {
t.Errorf("built-in %q has category %q; a built-in must be its own category's canonical", key, entry.Category)
}
}
}
// TestCatalogOrderMatchesHistoricalStatusOrder pins the default board order.
// A workspace with no custom statuses must list exactly as it did before this
// feature, or every existing user's board silently rearranges.
func TestCatalogOrderMatchesHistoricalStatusOrder(t *testing.T) {
seedTestCatalog(t)
entries, err := testHandler.Queries.ListIssueStatusEntries(context.Background(), db.ListIssueStatusEntriesParams{
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("list catalog: %v", err)
}
var gotSystem []string
for _, e := range entries {
if e.IsSystem {
gotSystem = append(gotSystem, e.Key)
}
}
want := []string{"backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"}
for i := range want {
if i >= len(gotSystem) || gotSystem[i] != want[i] {
t.Fatalf("built-in order = %v, want %v (frontend STATUS_ORDER)", gotSystem, want)
}
}
}
// TestUnseededWorkspaceStillAcceptsBuiltInStatuses is the regression guard for
// the failure mode found while wiring this up: requiring a catalog row to
// validate a status made every issue write fail in a workspace whose seed had
// not landed. The built-ins must resolve with or without a row.
func TestUnseededWorkspaceStillAcceptsBuiltInStatuses(t *testing.T) {
ctx := context.Background()
// Strip the catalog to simulate a workspace created by a pod that predates
// the feature, or one the seed migration has not reached yet.
if _, err := testPool.Exec(ctx, `DELETE FROM issue_status WHERE workspace_id = $1`, parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("clear catalog: %v", err)
}
t.Cleanup(func() { seedTestCatalog(t) })
for _, key := range issuestatus.Canonical() {
if _, err := issuestatus.Resolve(ctx, testHandler.Queries, parseUUID(testWorkspaceID), key); err != nil {
t.Errorf("built-in %q must resolve in an unseeded workspace, got: %v", key, err)
}
if got := issuestatus.Effective(ctx, testHandler.Queries, parseUUID(testWorkspaceID), key); got != key {
t.Errorf("Effective(%q) = %q in an unseeded workspace, want the key unchanged", key, got)
}
}
// A non-built-in key still needs a catalog row, so failing open is scoped
// exactly to the set that was valid before this feature.
if _, err := issuestatus.Resolve(ctx, testHandler.Queries, parseUUID(testWorkspaceID), "human_review"); err == nil {
t.Error("a custom key with no catalog row must not resolve")
}
// The error message must never omit a key that Resolve accepts.
keys, err := issuestatus.ActiveKeys(ctx, testHandler.Queries, parseUUID(testWorkspaceID))
if err != nil {
t.Fatalf("ActiveKeys: %v", err)
}
if len(keys) != 7 {
t.Errorf("ActiveKeys on an unseeded workspace = %v, want the 7 built-ins", keys)
}
}
// TestCustomStatusInheritsItsCategoryBehavior is the core promise of the
// model: a custom status behaves as the canonical status it names.
func TestCustomStatusInheritsItsCategoryBehavior(t *testing.T) {
ctx := context.Background()
cases := []struct{ key, category string }{
{"human_review_t", issuestatus.InReview},
{"rework_t", issuestatus.Todo},
{"gate_approved_t", issuestatus.Done},
{"waiting_customer_t", issuestatus.Blocked},
{"triage_later_t", issuestatus.Backlog},
}
for _, tc := range cases {
createTestCustomStatus(t, tc.key, tc.category)
got := issuestatus.Effective(ctx, testHandler.Queries, parseUUID(testWorkspaceID), tc.key)
if got != tc.category {
t.Errorf("Effective(%q) = %q, want %q", tc.key, got, tc.category)
}
}
}
// TestIssueWriteAcceptsCustomStatusAndRejectsUnknown exercises the real HTTP
// write path, which is where the dropped enum CHECK has to be replaced.
func TestIssueWriteAcceptsCustomStatusAndRejectsUnknown(t *testing.T) {
createTestCustomStatus(t, "human_review_w", issuestatus.InReview)
t.Run("custom status is accepted", func(t *testing.T) {
req := newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "custom status write",
"status": "human_review_w",
})
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Status string `json:"status"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode: %v", err)
}
if created.Status != "human_review_w" {
t.Errorf("issue.status = %q, want the custom key stored verbatim", created.Status)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(created.ID))
})
})
t.Run("unknown status is rejected", func(t *testing.T) {
req := newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "unknown status write",
"status": "not_a_status",
})
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for an unknown status, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("archived status is rejected", func(t *testing.T) {
entry := createTestCustomStatus(t, "retired_w", issuestatus.InProgress)
if _, err := testHandler.Queries.ArchiveIssueStatusEntry(context.Background(), db.ArchiveIssueStatusEntryParams{
ID: entry.ID,
WorkspaceID: parseUUID(testWorkspaceID),
}); err != nil {
t.Fatalf("archive: %v", err)
}
req := newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "archived status write",
"status": "retired_w",
})
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for an archived status, got %d: %s", rec.Code, rec.Body.String())
}
})
}
// TestBuiltInStatusesAreImmutable pins the product decision that built-in
// statuses cannot be renamed, recolored, or archived, so the default workspace
// is identical for every user who never opens the settings page.
func TestBuiltInStatusesAreImmutable(t *testing.T) {
ctx := context.Background()
seedTestCatalog(t)
builtIn, err := testHandler.Queries.GetIssueStatusEntryByKey(ctx, db.GetIssueStatusEntryByKeyParams{
WorkspaceID: parseUUID(testWorkspaceID),
Key: "in_review",
})
if err != nil {
t.Fatalf("load built-in: %v", err)
}
t.Run("rename is refused at the API", func(t *testing.T) {
req := withURLParam(
newRequest(http.MethodPatch, "/api/issue-statuses/"+uuidToString(builtIn.ID), map[string]any{"name": "Renamed"}),
"id", uuidToString(builtIn.ID))
rec := httptest.NewRecorder()
testHandler.UpdateIssueStatus(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 renaming a built-in, got %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("archive is refused at the API", func(t *testing.T) {
req := withURLParam(
newRequest(http.MethodDelete, "/api/issue-statuses/"+uuidToString(builtIn.ID), nil),
"id", uuidToString(builtIn.ID))
rec := httptest.NewRecorder()
testHandler.ArchiveIssueStatus(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("expected 403 archiving a built-in, got %d: %s", rec.Code, rec.Body.String())
}
})
// Defense in depth: even a direct query cannot rename or archive a
// built-in, because the statements carry an is_system guard.
t.Run("storage layer refuses too", func(t *testing.T) {
if _, err := testHandler.Queries.UpdateIssueStatusEntry(ctx, db.UpdateIssueStatusEntryParams{
ID: builtIn.ID,
WorkspaceID: parseUUID(testWorkspaceID),
Name: pgtype.Text{String: "Renamed", Valid: true},
}); err == nil {
t.Error("UpdateIssueStatusEntry must not touch a built-in row")
}
if _, err := testHandler.Queries.ArchiveIssueStatusEntry(ctx, db.ArchiveIssueStatusEntryParams{
ID: builtIn.ID,
WorkspaceID: parseUUID(testWorkspaceID),
}); err == nil {
t.Error("ArchiveIssueStatusEntry must not touch a built-in row")
}
})
}
// TestArchiveRetiresStatusWithoutTouchingExistingIssues pins the archive rule:
// archiving retires a status from FUTURE use only. Issues already on it keep it
// and keep behaving as their category prescribes — retiring a label must not
// rewrite history.
func TestArchiveRetiresStatusWithoutTouchingExistingIssues(t *testing.T) {
ctx := context.Background()
entry := createTestCustomStatus(t, "in_use_a", issuestatus.InProgress)
issueID := mustCreateIssue(t, "occupies the status", "in_use_a")
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("archiving an in-use status should succeed, got %d", code)
}
// The existing issue keeps the archived status verbatim.
var status string
if err := testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`, issueID).Scan(&status); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "in_use_a" {
t.Errorf("existing issue status = %q, want it left on the archived status", status)
}
// And it still resolves to its category, so its platform behavior is
// unchanged — Effective deliberately ignores archived_at.
if got := issuestatus.Effective(ctx, testHandler.Queries, parseUUID(testWorkspaceID), "in_use_a"); got != issuestatus.InProgress {
t.Errorf("Effective on an archived status = %q, want %q", got, issuestatus.InProgress)
}
// But nothing NEW can be assigned to it.
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "should be refused", "status": "in_use_a",
}))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 assigning an archived status to a new issue, got %d: %s", rec.Code, rec.Body.String())
}
}
// TestCreateIssueStatusValidation covers the reserved-key and category rules.
func TestCreateIssueStatusValidation(t *testing.T) {
seedTestCatalog(t)
cases := []struct {
name string
body map[string]any
want int
}{
{"reserved built-in key", map[string]any{"name": "Mine", "key": "in_review", "category": "in_review", "color": "#123456"}, http.StatusBadRequest},
{"name slugifying onto a built-in", map[string]any{"name": "In Review", "category": "in_review", "color": "#123456"}, http.StatusBadRequest},
{"unknown category", map[string]any{"name": "Weird", "category": "started", "color": "#123456"}, http.StatusBadRequest},
{"missing category", map[string]any{"name": "Weird2", "color": "#123456"}, http.StatusBadRequest},
{"bad color", map[string]any{"name": "Weird3", "category": "todo", "color": "red"}, http.StatusBadRequest},
{"empty name", map[string]any{"name": " ", "category": "todo", "color": "#123456"}, http.StatusBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
testHandler.CreateIssueStatus(rec, newRequest(http.MethodPost, "/api/issue-statuses", tc.body))
if rec.Code != tc.want {
t.Fatalf("expected %d, got %d: %s", tc.want, rec.Code, rec.Body.String())
}
})
}
}
// TestCreateIssueStatusIsNotGatedOnRollout pins the removal of the rollout gate
// (MUL-6643). Creation used to 403 unless a feature flag was flipped on, because
// the first custom status minted a value older pods could not interpret. Every
// pod resolves categories now, so an unset flag must not be able to take the
// feature away again — creation answers to workspace role alone.
func TestCreateIssueStatusIsNotGatedOnRollout(t *testing.T) {
seedTestCatalog(t)
var created IssueStatusResponse
testutil.Call(t, testHandler.CreateIssueStatus,
newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": "Human Review", "category": issuestatus.InReview, "color": "#123456",
})).Want(http.StatusCreated).JSON(&created)
dbfx.Cleanup(t, `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
}
// TestIssueWriteStoresCanonicalStatusKey guards the 500 found in review:
// resolution is case- and whitespace-insensitive, so writing the caller's raw
// string back would store a value the column's format constraint rejects.
func TestIssueWriteStoresCanonicalStatusKey(t *testing.T) {
createTestCustomStatus(t, "human_review_c", issuestatus.InReview)
for _, input := range []string{"HUMAN_REVIEW_C", " human_review_c ", "Human_Review_C"} {
t.Run(input, func(t *testing.T) {
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "canonical key " + input, "status": input,
}))
if rec.Code != http.StatusCreated {
t.Fatalf("expected 201 for %q, got %d: %s", input, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
Status string `json:"status"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(created.ID))
})
if created.Status != "human_review_c" {
t.Errorf("stored status = %q, want the canonical key %q", created.Status, "human_review_c")
}
// The stored value must satisfy the column's format constraint; a
// non-canonical write would have failed as a 500 above, but assert
// the persisted row too so the guarantee is explicit.
var persisted string
if err := testPool.QueryRow(context.Background(),
`SELECT status FROM issue WHERE id = $1`, parseUUID(created.ID)).Scan(&persisted); err != nil {
t.Fatalf("read back: %v", err)
}
if persisted != "human_review_c" {
t.Errorf("persisted status = %q, want %q", persisted, "human_review_c")
}
})
}
}
// TestCustomTerminalStatusCountsAsTerminalInSQL covers the SQL-side consumers
// the Go resolver cannot reach. Terminal categories are expanded once into
// concrete keys so custom done statuses preserve their behavior without a
// per-row issue_effective_status call.
func TestCustomTerminalStatusCountsAsTerminalInSQL(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "gate_approved_s", issuestatus.Done)
createTestCustomStatus(t, "gate_archived_s", issuestatus.Done)
if _, err := testPool.Exec(ctx, `
UPDATE issue_status
SET archived_at = now()
WHERE workspace_id = $1 AND key = 'gate_archived_s'`, parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("archive custom terminal status: %v", err)
}
terminalStatusKeys, err := issuestatus.ExpandCategories(
ctx,
testHandler.Queries,
parseUUID(testWorkspaceID),
[]string{issuestatus.Done, issuestatus.Cancelled},
)
if err != nil {
t.Fatalf("expand terminal status categories: %v", err)
}
if !slices.Contains(terminalStatusKeys, "gate_archived_s") {
t.Fatalf("expanded terminal keys %v do not include archived custom status", terminalStatusKeys)
}
mkIssue := func(title, status string) pgtype.UUID {
t.Helper()
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": title, "status": status,
}))
if rec.Code != http.StatusCreated {
t.Fatalf("create %q: %d %s", title, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
id := parseUUID(created.ID)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, id) })
return id
}
t.Run("duplicate guard treats it as closed", func(t *testing.T) {
title := "sql terminal duplicate probe"
mkIssue(title, "gate_approved_s")
if _, err := testHandler.Queries.FindActiveDuplicateIssue(ctx, db.FindActiveDuplicateIssueParams{
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
NormalizedTitle: title,
}); err == nil {
t.Error("an issue on a custom done status must not count as an active duplicate")
}
})
t.Run("open issue listing excludes it", func(t *testing.T) {
customDoneID := mkIssue("sql open-list custom done", "gate_approved_s")
openID := mkIssue("sql open-list active", "todo")
unknownID := parseUUID(dbfx.Issue(t, "sql open-list unknown legacy status", testutil.Cols{
"status": "legacy_unknown",
}))
rows, err := testHandler.Queries.ListOpenIssues(ctx, db.ListOpenIssuesParams{
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("ListOpenIssues: %v", err)
}
var sawCustomDone, sawOpen, sawUnknown bool
for _, row := range rows {
sawCustomDone = sawCustomDone || row.ID == customDoneID
sawOpen = sawOpen || row.ID == openID
sawUnknown = sawUnknown || row.ID == unknownID
}
if sawCustomDone || !sawOpen || !sawUnknown {
t.Fatalf("open listing customDone/open/unknown = %v/%v/%v, want false/true/true", sawCustomDone, sawOpen, sawUnknown)
}
})
t.Run("project completion counts it as done", func(t *testing.T) {
var projectID pgtype.UUID
if err := testPool.QueryRow(ctx,
`INSERT INTO project (workspace_id, title) VALUES ($1, 'Status SQL Probe') RETURNING id`,
parseUUID(testWorkspaceID)).Scan(&projectID); err != nil {
t.Fatalf("create project fixture: %v", err)
}
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM project WHERE id = $1`, projectID) })
for _, id := range []pgtype.UUID{
mkIssue("sql project custom done", "gate_approved_s"),
mkIssue("sql project open", "todo"),
} {
if _, err := testPool.Exec(ctx, `UPDATE issue SET project_id = $1 WHERE id = $2`, projectID, id); err != nil {
t.Fatalf("attach to project: %v", err)
}
}
foreignWorkspaceID := dbfx.Workspace(t, "Foreign project stats", fmt.Sprintf("foreign-project-stats-%d", time.Now().UnixNano()))
dbfx.Issue(t, "foreign workspace issue with mismatched project", testutil.Cols{
"workspace_id": foreignWorkspaceID,
"project_id": uuidToString(projectID),
"status": "todo",
})
stats, err := testHandler.Queries.GetProjectIssueStats(ctx, db.GetProjectIssueStatsParams{
WorkspaceID: parseUUID(testWorkspaceID),
ProjectIds: []pgtype.UUID{projectID},
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("GetProjectIssueStats: %v", err)
}
if len(stats) != 1 {
t.Fatalf("expected stats for one project, got %d", len(stats))
}
if stats[0].TotalCount != 2 || stats[0].DoneCount != 1 {
t.Errorf("project stats = %d done / %d total, want 1/2 (the custom done status must count)",
stats[0].DoneCount, stats[0].TotalCount)
}
})
t.Run("sub-issue progress counts it as done", func(t *testing.T) {
parent := mkIssue("sql child progress parent", "in_progress")
for _, id := range []pgtype.UUID{
mkIssue("sql child custom done", "gate_approved_s"),
mkIssue("sql child open", "todo"),
} {
if _, err := testPool.Exec(ctx, `UPDATE issue SET parent_issue_id = $1 WHERE id = $2`, parent, id); err != nil {
t.Fatalf("attach child: %v", err)
}
}
rows, err := testHandler.Queries.ChildIssueProgress(ctx, db.ChildIssueProgressParams{
WorkspaceID: parseUUID(testWorkspaceID),
TerminalStatusKeys: terminalStatusKeys,
})
if err != nil {
t.Fatalf("ChildIssueProgress: %v", err)
}
for _, row := range rows {
if row.ParentIssueID == parent {
if row.Total != 2 || row.Done != 1 {
t.Errorf("child progress = %d done / %d total, want 1/2 (the custom done status must count)",
row.Done, row.Total)
}
return
}
}
t.Error("no progress row for the parent issue")
})
}
// archiveStatusVia runs the real archive endpoint and returns its status code.
func archiveStatusVia(t *testing.T, entry db.IssueStatus) int {
t.Helper()
rec := httptest.NewRecorder()
testHandler.ArchiveIssueStatus(rec, withURLParam(
newRequest(http.MethodDelete, "/api/issue-statuses/"+uuidToString(entry.ID), nil),
"id", uuidToString(entry.ID)))
return rec.Code
}
// holdExclusiveCatalogLock takes the archive side of the catalog lock and holds
// it until the returned release func runs, so a test can pin one ordering.
func holdExclusiveCatalogLock(t *testing.T) (release func()) {
t.Helper()
ctx := context.Background()
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin lock holder: %v", err)
}
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':issue_status', 0))`,
parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("take exclusive lock: %v", err)
}
released := false
return func() {
if released {
return
}
released = true
tx.Rollback(ctx)
}
}
// TestWritesRejectAnArchivedStatus covers the SEQUENTIAL case: the status is
// already archived before the request arrives, so the pre-flight validation
// alone is enough to refuse it.
//
// This is deliberately NOT the race test — no archive is interleaved with a
// write here, and these cases would pass even without the in-transaction
// re-resolve. The interleaved counterexample is
// TestArchiveCommitsInsideTheWriteRaceWindow below.
func TestWritesRejectAnArchivedStatus(t *testing.T) {
ctx := context.Background()
for _, tc := range []struct {
name string
key string
write func(t *testing.T, key string) int
}{
{"create", "race_a_create", func(t *testing.T, key string) int {
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "race create " + key, "status": key,
}))
return rec.Code
}},
{"update", "race_a_update", func(t *testing.T, key string) int {
seed := mustCreateIssue(t, "race update seed "+key, "todo")
rec := httptest.NewRecorder()
testHandler.UpdateIssue(rec, withURLParam(
newRequest(http.MethodPatch, "/api/issues/"+uuidToString(seed), map[string]any{"status": key}),
"id", uuidToString(seed)))
return rec.Code
}},
{"update with description merge", "race_a_upd_merge", func(t *testing.T, key string) int {
seed := mustCreateIssue(t, "race merge seed "+key, "todo")
rec := httptest.NewRecorder()
testHandler.UpdateIssue(rec, withURLParam(
newRequest(http.MethodPatch, "/api/issues/"+uuidToString(seed), map[string]any{
"status": key, "description": "merged body",
}),
"id", uuidToString(seed)))
return rec.Code
}},
{"batch", "race_a_batch", func(t *testing.T, key string) int {
seed := mustCreateIssue(t, "race batch seed "+key, "todo")
rec := httptest.NewRecorder()
testHandler.BatchUpdateIssues(rec, newRequest(http.MethodPatch, "/api/issues/batch", map[string]any{
"issue_ids": []string{uuidToString(seed)},
"updates": map[string]any{"status": key},
}))
return rec.Code
}},
{"batch with description merge", "race_a_batch_merge", func(t *testing.T, key string) int {
seed := mustCreateIssue(t, "race batch merge seed "+key, "todo")
rec := httptest.NewRecorder()
testHandler.BatchUpdateIssues(rec, newRequest(http.MethodPatch, "/api/issues/batch", map[string]any{
"issue_ids": []string{uuidToString(seed)},
"updates": map[string]any{"status": key, "description": "merged body"},
}))
return rec.Code
}},
} {
t.Run(tc.name, func(t *testing.T) {
key := tc.key
entry := createTestCustomStatus(t, key, issuestatus.InProgress)
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("archive setup returned %d", code)
}
code := tc.write(t, key)
if code == http.StatusOK || code == http.StatusCreated {
t.Fatalf("%s succeeded (%d) against an archived status", tc.name, code)
}
// Whatever the code, the invariant is that nothing references the
// archived status.
var stranded int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM issue WHERE workspace_id = $1 AND status = $2`,
parseUUID(testWorkspaceID), key).Scan(&stranded); err != nil {
t.Fatalf("count stranded: %v", err)
}
if stranded != 0 {
t.Errorf("%d issue(s) left referencing archived status %q", stranded, key)
}
})
}
}
// TestArchiveSucceedsAfterACommittedWrite is the other ordering: an issue
// committed on the status does not block archiving it, because archiving only
// retires it from future use.
func TestArchiveSucceedsAfterACommittedWrite(t *testing.T) {
ctx := context.Background()
t.Run("writer wins: archive still succeeds and leaves the issue alone", func(t *testing.T) {
entry := createTestCustomStatus(t, "race_b_writer", issuestatus.InProgress)
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "race writer wins", "status": "race_b_writer",
}))
if rec.Code != http.StatusCreated {
t.Fatalf("create on custom status: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
t.Cleanup(func() { testPool.Exec(ctx, `DELETE FROM issue WHERE id = $1`, parseUUID(created.ID)) })
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("archive after a committed write = %d, want 200", code)
}
var status string
if err := testPool.QueryRow(ctx, `SELECT status FROM issue WHERE id = $1`,
parseUUID(created.ID)).Scan(&status); err != nil {
t.Fatalf("read back: %v", err)
}
if status != "race_b_writer" {
t.Errorf("issue status = %q, want it untouched by the archive", status)
}
})
// The writer must genuinely BLOCK on the archive's exclusive lock rather
// than racing past it — otherwise the interleaved case below would only be
// closed by luck.
t.Run("writer blocks while archive holds the exclusive lock", func(t *testing.T) {
createTestCustomStatus(t, "race_block", issuestatus.InProgress)
release := holdExclusiveCatalogLock(t)
defer release()
done := make(chan int, 1)
go func() {
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "race blocked writer", "status": "race_block",
}))
done <- rec.Code
}()
select {
case code := <-done:
t.Fatalf("write completed (%d) while the archive lock was held", code)
case <-time.After(400 * time.Millisecond):
// Blocked as required.
}
release()
select {
case code := <-done:
if code != http.StatusCreated {
t.Fatalf("write after the lock released = %d, want 201", code)
}
testPool.Exec(ctx, `DELETE FROM issue WHERE workspace_id = $1 AND status = 'race_block'`,
parseUUID(testWorkspaceID))
case <-time.After(10 * time.Second):
t.Fatal("write never completed after the archive lock released")
}
})
}
// TestArchiveCommitsInsideTheWriteRaceWindow is the sharp counterexample, run
// against EVERY write endpoint that can land on a custom status.
//
// It interleaves the two operations so the writer's PRE-FLIGHT validation
// observes an active status and the archive commits before the write lands.
// That is the exact hole the first implementation had: the pre-flight Resolve
// ran outside the transaction and was never rechecked, so the writer sailed
// past the released lock and stored an archived key. Verified to fail without
// the in-transaction re-resolve (the issue is left stranded) and pass with it.
//
// Seed issues for the update/batch cases are created BEFORE the exclusive lock
// is taken, so the only thing racing the archive is the status write itself.
func TestArchiveCommitsInsideTheWriteRaceWindow(t *testing.T) {
cases := []struct {
name string
key string
seed func(t *testing.T, key string) pgtype.UUID
write func(t *testing.T, key string, seed pgtype.UUID) int
}{
{
name: "create",
key: "win_create",
write: func(t *testing.T, key string, _ pgtype.UUID) int {
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "race window create", "status": key,
}))
return rec.Code
},
},
{
name: "update",
key: "win_update",
seed: func(t *testing.T, key string) pgtype.UUID {
return mustCreateIssue(t, "race window update "+key, "todo")
},
write: func(t *testing.T, key string, seed pgtype.UUID) int {
rec := httptest.NewRecorder()
testHandler.UpdateIssue(rec, withURLParam(
newRequest(http.MethodPatch, "/api/issues/"+uuidToString(seed), map[string]any{"status": key}),
"id", uuidToString(seed)))
return rec.Code
},
},
{
name: "update with description merge",
key: "win_upd_merge",
seed: func(t *testing.T, key string) pgtype.UUID {
return mustCreateIssue(t, "race window merge "+key, "todo")
},
write: func(t *testing.T, key string, seed pgtype.UUID) int {
rec := httptest.NewRecorder()
testHandler.UpdateIssue(rec, withURLParam(
newRequest(http.MethodPatch, "/api/issues/"+uuidToString(seed), map[string]any{
"status": key, "description": "merged body",
}),
"id", uuidToString(seed)))
return rec.Code
},
},
{
name: "batch",
key: "win_batch",
seed: func(t *testing.T, key string) pgtype.UUID {
return mustCreateIssue(t, "race window batch "+key, "todo")
},
write: func(t *testing.T, key string, seed pgtype.UUID) int {
rec := httptest.NewRecorder()
testHandler.BatchUpdateIssues(rec, newRequest(http.MethodPatch, "/api/issues/batch", map[string]any{
"issue_ids": []string{uuidToString(seed)},
"updates": map[string]any{"status": key},
}))
return rec.Code
},
},
{
name: "batch with description merge",
key: "win_batch_merge",
seed: func(t *testing.T, key string) pgtype.UUID {
return mustCreateIssue(t, "race window batch merge "+key, "todo")
},
write: func(t *testing.T, key string, seed pgtype.UUID) int {
rec := httptest.NewRecorder()
testHandler.BatchUpdateIssues(rec, newRequest(http.MethodPatch, "/api/issues/batch", map[string]any{
"issue_ids": []string{uuidToString(seed)},
"updates": map[string]any{"status": key, "description": "merged body"},
}))
return rec.Code
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
entry := createTestCustomStatus(t, tc.key, issuestatus.InProgress)
var seedID pgtype.UUID
if tc.seed != nil {
seedID = tc.seed(t, tc.key)
}
// Hold the archive side first, so the writer is guaranteed to park
// on the lock AFTER its pre-flight validation (which runs outside
// any transaction) and BEFORE its write.
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':issue_status', 0))`,
parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("take exclusive lock: %v", err)
}
done := make(chan int, 1)
go func() { done <- tc.write(t, tc.key, seedID) }()
select {
case code := <-done:
t.Fatalf("write completed (%d) before the archive released the lock", code)
case <-time.After(400 * time.Millisecond):
// Parked on the lock, as required.
}
// Archive inside the held lock and commit: from the writer's point
// of view the status was active at validation time and archived by
// write time.
if _, err := tx.Exec(ctx,
`UPDATE issue_status SET archived_at = now() WHERE id = $1`, entry.ID); err != nil {
t.Fatalf("archive inside the window: %v", err)
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("commit archive: %v", err)
}
select {
case code := <-done:
if code == http.StatusOK || code == http.StatusCreated {
t.Errorf("%s succeeded (%d) against a status archived inside the race window", tc.name, code)
}
case <-time.After(10 * time.Second):
t.Fatal("write never completed after the archive committed")
}
var stranded int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM issue WHERE workspace_id = $1 AND status = $2`,
parseUUID(testWorkspaceID), tc.key).Scan(&stranded); err != nil {
t.Fatalf("count stranded: %v", err)
}
if stranded != 0 {
t.Errorf("%d issue(s) stranded on a status archived inside the race window", stranded)
}
})
}
}
// mustCreateIssue creates an issue through the real endpoint and returns its id.
func mustCreateIssue(t *testing.T, title, status string) pgtype.UUID {
t.Helper()
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": title, "status": status,
}))
if rec.Code != http.StatusCreated {
t.Fatalf("seed issue %q: %d %s", title, rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
id := parseUUID(created.ID)
t.Cleanup(func() { testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, id) })
return id
}
// TestBatchCustomTerminalStatusEntersStageBarrier is the regression guard for
// the batch stage-barrier prefilter. It compared status literals, so a batch
// that moved the last child onto a CUSTOM done status left childDoneCompleted
// empty and skipped the parent notification entirely — the already-fixed
// barrier logic downstream never ran.
func TestBatchCustomTerminalStatusEntersStageBarrier(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "batch_done_s", issuestatus.Done)
parent := mustCreateIssue(t, "batch barrier parent", "in_progress")
child := mustCreateIssue(t, "batch barrier child", "todo")
if _, err := testPool.Exec(ctx, `UPDATE issue SET parent_issue_id = $1 WHERE id = $2`, parent, child); err != nil {
t.Fatalf("attach child: %v", err)
}
rec := httptest.NewRecorder()
testHandler.BatchUpdateIssues(rec, newRequest(http.MethodPatch, "/api/issues/batch", map[string]any{
"issue_ids": []string{uuidToString(child)},
"updates": map[string]any{"status": "batch_done_s"},
}))
if rec.Code != http.StatusOK {
t.Fatalf("batch update: %d %s", rec.Code, rec.Body.String())
}
// The observable effect of entering the barrier is the system comment on
// the parent. Without the fix the batch is silent.
var comments int
if err := testPool.QueryRow(ctx,
`SELECT count(*) FROM comment WHERE issue_id = $1`, parent).Scan(&comments); err != nil {
t.Fatalf("count parent comments: %v", err)
}
if comments == 0 {
t.Error("moving the last child to a custom done status did not close the stage barrier")
}
}
// TestChildrenResponseCarriesStatusCategory pins the field the CLI's
// `issue children` stage progress reads. Without it the CLI counts only literal
// done/cancelled and reports wrong progress to an agent.
func TestChildrenResponseCarriesStatusCategory(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "children_done_s", issuestatus.Done)
parent := mustCreateIssue(t, "children category parent", "in_progress")
child := mustCreateIssue(t, "children category child", "children_done_s")
if _, err := testPool.Exec(ctx, `UPDATE issue SET parent_issue_id = $1 WHERE id = $2`, parent, child); err != nil {
t.Fatalf("attach child: %v", err)
}
rec := httptest.NewRecorder()
testHandler.ListChildIssues(rec, withURLParam(
newRequest(http.MethodGet, "/api/issues/"+uuidToString(parent)+"/children", nil),
"id", uuidToString(parent)))
if rec.Code != http.StatusOK {
t.Fatalf("list children: %d %s", rec.Code, rec.Body.String())
}
var payload struct {
Issues []struct {
Status string `json:"status"`
StatusCategory string `json:"status_category"`
} `json:"issues"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
if len(payload.Issues) != 1 {
t.Fatalf("expected 1 child, got %d: %s", len(payload.Issues), rec.Body.String())
}
if payload.Issues[0].Status != "children_done_s" {
t.Errorf("status = %q, want the custom key", payload.Issues[0].Status)
}
if payload.Issues[0].StatusCategory != "done" {
t.Errorf("status_category = %q, want %q", payload.Issues[0].StatusCategory, "done")
}
}
// TestCategoryFilterExpandsToIndexedStatusKeys is the regression guard for the
// performance fix: filtering by category must expand to concrete status keys so
// `status = ANY(...)` can use the (workspace_id, status) index. Wrapping the
// column in issue_effective_status() made that index unusable and turned a
// two-page index read into a full workspace scan.
func TestCategoryFilterExpandsToIndexedStatusKeys(t *testing.T) {
ctx := context.Background()
seedTestCatalog(t)
ws := parseUUID(testWorkspaceID)
t.Run("built-in category expands to exactly its own key", func(t *testing.T) {
keys, err := issuestatus.ExpandCategories(ctx, testHandler.Queries, ws, []string{"blocked"})
if err != nil {
t.Fatalf("expand: %v", err)
}
if len(keys) != 1 || keys[0] != "blocked" {
t.Errorf("expand(blocked) = %v, want exactly [blocked] — a workspace with no "+
"custom statuses must produce the pre-feature query", keys)
}
})
t.Run("category includes its custom statuses", func(t *testing.T) {
createTestCustomStatus(t, "human_review_x", issuestatus.InReview)
keys, err := issuestatus.ExpandCategories(ctx, testHandler.Queries, ws, []string{"in_review"})
if err != nil {
t.Fatalf("expand: %v", err)
}
if !slices.Contains(keys, "in_review") || !slices.Contains(keys, "human_review_x") {
t.Errorf("expand(in_review) = %v, want both the canonical and the custom key", keys)
}
})
// An issue left on an archived status still belongs in its category's
// column, so the expansion must keep archived keys.
t.Run("archived statuses stay in their category", func(t *testing.T) {
entry := createTestCustomStatus(t, "retired_x", issuestatus.Done)
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("archive: %d", code)
}
keys, err := issuestatus.ExpandCategories(ctx, testHandler.Queries, ws, []string{"done"})
if err != nil {
t.Fatalf("expand: %v", err)
}
if !slices.Contains(keys, "retired_x") {
t.Errorf("expand(done) = %v, want the archived key included so issues on it "+
"still appear in the Done column", keys)
}
})
// A workspace whose seed has not landed must still filter correctly.
t.Run("unseeded workspace falls back to the canonical keys", func(t *testing.T) {
if _, err := testPool.Exec(ctx, `DELETE FROM issue_status WHERE workspace_id = $1`, ws); err != nil {
t.Fatalf("clear catalog: %v", err)
}
t.Cleanup(func() { seedTestCatalog(t) })
keys, err := issuestatus.ExpandCategories(ctx, testHandler.Queries, ws, []string{"todo", "done"})
if err != nil {
t.Fatalf("expand: %v", err)
}
if len(keys) != 2 || !slices.Contains(keys, "todo") || !slices.Contains(keys, "done") {
t.Errorf("expand on an unseeded workspace = %v, want [todo done]", keys)
}
})
}
// TestListFilterByCategoryReturnsCustomStatusIssues exercises the real endpoint.
func TestListFilterByCategoryReturnsCustomStatusIssues(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "human_review_l", issuestatus.InReview)
customID := mustCreateIssue(t, "custom in review", "human_review_l")
builtinID := mustCreateIssue(t, "builtin in review", "in_review")
otherID := mustCreateIssue(t, "unrelated todo", "todo")
_ = ctx
rec := httptest.NewRecorder()
testHandler.ListIssues(rec, newRequest(http.MethodGet, "/api/issues?status_category=in_review&limit=100", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
}
var payload struct {
Issues []struct {
ID string `json:"id"`
Status string `json:"status"`
StatusCategory string `json:"status_category"`
} `json:"issues"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode: %v", err)
}
got := map[string]string{}
for _, i := range payload.Issues {
got[i.ID] = i.StatusCategory
}
if _, ok := got[uuidToString(customID)]; !ok {
t.Error("the in_review category must include issues on a custom in_review status")
}
if _, ok := got[uuidToString(builtinID)]; !ok {
t.Error("the in_review category must include issues on the built-in status")
}
if _, ok := got[uuidToString(otherID)]; ok {
t.Error("the in_review category must not include a todo issue")
}
// Every row carries an authoritative category, custom statuses included —
// this is what the client buckets and caches by.
if c := got[uuidToString(customID)]; c != "in_review" {
t.Errorf("custom-status row status_category = %q, want %q", c, "in_review")
}
}
// TestCreateEventCarriesCustomStatusCategory is the regression guard for the
// websocket path: filling the category only on the HTTP response is too late
// for other tabs, which receive the broadcast payload. Without it they cannot
// bucket the new issue and must fall back to a full refetch.
func TestCreateEventCarriesCustomStatusCategory(t *testing.T) {
createTestCustomStatus(t, "human_review_ev", issuestatus.InReview)
gotEvent := make(chan events.Event, 1)
testHandler.Bus.Subscribe(protocol.EventIssueCreated, func(e events.Event) {
select {
case gotEvent <- e:
default:
}
})
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "custom status event", "status": "human_review_ev",
}))
if rec.Code != http.StatusCreated {
t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
}
var created struct {
ID string `json:"id"`
StatusCategory string `json:"status_category"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(created.ID))
})
if created.StatusCategory != "in_review" {
t.Errorf("HTTP response status_category = %q, want in_review", created.StatusCategory)
}
select {
case e := <-gotEvent:
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("event payload shape: %T", e.Payload)
}
issue, ok := payload["issue"].(IssueResponse)
if !ok {
t.Fatalf("event issue shape: %T", payload["issue"])
}
if issue.StatusCategory != "in_review" {
t.Errorf("event status_category = %q, want in_review — other tabs cannot "+
"bucket the new issue without it", issue.StatusCategory)
}
case <-time.After(3 * time.Second):
t.Fatal("no issue:created event")
}
}
// TestListEndpointsCarryStatusCategory covers the remaining payload exits: every
// row a list or get returns must carry an authoritative category, custom
// statuses included. It deliberately does NOT assert the per-request catalog
// read count — see the note on the resolver subtest below for why.
func TestListEndpointsCarryStatusCategory(t *testing.T) {
createTestCustomStatus(t, "human_review_n", issuestatus.InReview)
for i := range 3 {
mustCreateIssue(t, fmt.Sprintf("n+1 probe %d", i), "human_review_n")
}
t.Run("list carries category for every custom row", func(t *testing.T) {
rec := httptest.NewRecorder()
testHandler.ListIssues(rec, newRequest(http.MethodGet, "/api/issues?status_category=in_review&limit=100", nil))
if rec.Code != http.StatusOK {
t.Fatalf("list: %d %s", rec.Code, rec.Body.String())
}
var payload struct {
Issues []struct {
Status string `json:"status"`
StatusCategory string `json:"status_category"`
} `json:"issues"`
}
json.Unmarshal(rec.Body.Bytes(), &payload)
seen := 0
for _, i := range payload.Issues {
if i.Status == "human_review_n" {
seen++
if i.StatusCategory != "in_review" {
t.Errorf("custom row status_category = %q, want in_review", i.StatusCategory)
}
}
}
if seen < 3 {
t.Errorf("expected the 3 custom-status rows in the in_review category, saw %d", seen)
}
})
// NOTE ON COVERAGE: there is deliberately no assertion here that the handler
// reads the catalog only once per page. pg_stat_user_tables counters lag
// behind the queries that produced them, so a delta around one request does
// not reliably distinguish one read from N — a threshold written against it
// passes even with a per-row filler, which would make this test claim more
// than it proves. Observing it properly needs a counting Querier injected
// into the handler, which it does not support today.
//
// What IS pinned: the Resolver amortizes reads (below), and every list site
// constructs its filler OUTSIDE the loop, which is what makes that
// amortization reach the handler.
t.Run("the resolver amortizes repeated lookups", func(t *testing.T) {
r := issuestatus.NewResolver(parseUUID(testWorkspaceID))
ctx := context.Background()
for range 10 {
if got := r.Effective(ctx, testHandler.Queries, "human_review_n"); got != "in_review" {
t.Fatalf("Effective = %q, want in_review", got)
}
}
})
t.Run("get carries category", func(t *testing.T) {
id := mustCreateIssue(t, "get carries category", "human_review_n")
rec := httptest.NewRecorder()
testHandler.GetIssue(rec, withURLParam(
newRequest(http.MethodGet, "/api/issues/"+uuidToString(id), nil), "id", uuidToString(id)))
if rec.Code != http.StatusOK {
t.Fatalf("get: %d %s", rec.Code, rec.Body.String())
}
var got struct {
StatusCategory string `json:"status_category"`
}
json.Unmarshal(rec.Body.Bytes(), &got)
if got.StatusCategory != "in_review" {
t.Errorf("GetIssue status_category = %q, want in_review", got.StatusCategory)
}
})
}
// TestBackgroundEventCarriesCustomStatusCategory pins the background-event
// payload. IssueToMap fills a category only for built-ins; the publishers that
// can emit a CUSTOM status go through IssueToMapResolved so clients receive
// an authoritative one instead of a blank they would have to refetch to resolve.
func TestBackgroundEventCarriesCustomStatusCategory(t *testing.T) {
ctx := context.Background()
createTestCustomStatus(t, "human_review_bg", issuestatus.InReview)
id := mustCreateIssue(t, "background event category", "human_review_bg")
issue, err := testHandler.Queries.GetIssue(ctx, id)
if err != nil {
t.Fatalf("load issue: %v", err)
}
// Plain IssueToMap leaves a custom status uncategorized — that is why the
// publishers use the WithCategory form.
plain := service.IssueToMap(issue, "MUL")
if plain["status_category"] != "" {
t.Errorf("IssueToMap custom status_category = %v, want empty (no catalog access)",
plain["status_category"])
}
authoritative := service.IssueToMapResolved(ctx, testHandler.Queries, issue, "MUL")
if authoritative["status_category"] != "in_review" {
t.Errorf("IssueToMapResolved status_category = %v, want in_review",
authoritative["status_category"])
}
if authoritative["status"] != "human_review_bg" {
t.Errorf("status = %v, want the custom key verbatim", authoritative["status"])
}
}
// TestCatalogWritesAnnounceThemselves pins the realtime contract for the status
// catalog (MUL-6458): every write that changes it publishes exactly one
// workspace-scoped event, and a write that changes nothing publishes none.
//
// One event type covers all four writes because every client answers them the
// same way — re-read the catalog. What the assertions below are really
// protecting is the routing: an event with an empty WorkspaceID is dropped by
// the SubscribeAll forwarder without a word, so the catalog would look
// synchronized in tests and silently never reach another tab.
func TestCatalogWritesAnnounceThemselves(t *testing.T) {
ctx := context.Background()
seedTestCatalog(t)
changes := make(chan events.Event, 8)
testHandler.Bus.Subscribe(protocol.EventIssueStatusChanged, func(e events.Event) {
select {
case changes <- e:
default:
}
})
expectChange := func(t *testing.T, action string) {
t.Helper()
select {
case e := <-changes:
if e.WorkspaceID != testWorkspaceID {
t.Errorf("event workspace_id = %q, want %q — an event without one never "+
"reaches a client", e.WorkspaceID, testWorkspaceID)
}
if e.ActorType != "member" || e.ActorID == "" {
t.Errorf("event actor = %q/%q, want the acting member", e.ActorType, e.ActorID)
}
payload, ok := e.Payload.(map[string]any)
if !ok {
t.Fatalf("event payload shape: %T", e.Payload)
}
if payload["action"] != action {
t.Errorf("event action = %v, want %q", payload["action"], action)
}
case <-time.After(3 * time.Second):
t.Fatalf("no issue_status:changed event for %s — other tabs keep the stale catalog "+
"until its 5 minute staleTime expires", action)
}
}
expectNoChange := func(t *testing.T) {
t.Helper()
select {
case e := <-changes:
t.Fatalf("unexpected issue_status:changed event: %v", e.Payload)
case <-time.After(300 * time.Millisecond):
}
}
var created IssueStatusResponse
testutil.Call(t, testHandler.CreateIssueStatus,
newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": "Realtime Gate", "category": issuestatus.InReview, "color": "#123456",
})).Want(http.StatusCreated).JSON(&created)
dbfx.Cleanup(t, `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
expectChange(t, "created")
testutil.Call(t, testHandler.UpdateIssueStatus, withURLParam(
newRequest(http.MethodPatch, "/api/issue-statuses/"+created.ID, map[string]any{"name": "Renamed Gate"}),
"id", created.ID)).Want(http.StatusOK)
expectChange(t, "updated")
// Reorder demands EVERY active custom status in the category, so the
// payload is read back rather than assumed — a status left over from
// another test would otherwise turn this into a 409.
active, err := testHandler.Queries.ListActiveCustomIssueStatusEntries(ctx, db.ListActiveCustomIssueStatusEntriesParams{
WorkspaceID: parseUUID(testWorkspaceID),
Category: issuestatus.InReview,
})
if err != nil {
t.Fatalf("list active custom statuses: %v", err)
}
ids := make([]string, len(active))
for i, entry := range active {
ids[i] = uuidToString(entry.ID)
}
if code := reorderVia(t, issuestatus.InReview, ids).Code; code != http.StatusOK {
t.Fatalf("reorder: %d", code)
}
expectChange(t, "reordered")
entry, err := testHandler.Queries.GetIssueStatusEntryByID(ctx, db.GetIssueStatusEntryByIDParams{
ID: parseUUID(created.ID),
WorkspaceID: parseUUID(testWorkspaceID),
})
if err != nil {
t.Fatalf("reload created status: %v", err)
}
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("archive: %d", code)
}
expectChange(t, "archived")
// Archiving an already-archived status is a 200 no-op. Announcing it would
// have every other client re-read a catalog that did not move.
if code := archiveStatusVia(t, entry); code != http.StatusOK {
t.Fatalf("second archive: %d", code)
}
expectNoChange(t)
}
// TestCreateIssueStatusAcceptsANonLatinName is the regression for MUL-6749 /
// GitHub #7627. A display name written entirely in a non-Latin script has no
// characters in the key alphabet, so key derivation used to fail the create
// outright — and the settings form has no field for an explicit key, which left
// no way to create the status at all.
func TestCreateIssueStatusAcceptsANonLatinName(t *testing.T) {
seedTestCatalog(t)
create := func(t *testing.T, name, category string) IssueStatusResponse {
t.Helper()
var created IssueStatusResponse
testutil.Call(t, testHandler.CreateIssueStatus,
newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": name, "category": category, "color": "#123456",
})).Want(http.StatusCreated).JSON(&created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
})
return created
}
first := create(t, "客户确认", issuestatus.InReview)
if first.Key != "in_review_2" {
t.Errorf("derived key = %q, want %q", first.Key, "in_review_2")
}
if first.Name != "客户确认" {
t.Errorf("display name = %q, want it stored verbatim", first.Name)
}
// A second one in the same category takes the next ordinal instead of
// colliding with the first.
second := create(t, "供应商确认", issuestatus.InReview)
if second.Key != "in_review_3" {
t.Errorf("second derived key = %q, want %q", second.Key, "in_review_3")
}
// A name that CAN be slugged is untouched by the fallback.
english := create(t, "Human Review Zh", issuestatus.InReview)
if english.Key != "human_review_zh" {
t.Errorf("sluggable name derived %q, want %q", english.Key, "human_review_zh")
}
}
// TestCreateIssueStatusDisambiguatesCollidingSlugs covers the sharper half of
// #7627. The bug report's suggested workaround was to mix ASCII into the
// display name, but the non-ASCII part is dropped, so two DIFFERENT names
// collapse onto one key — and the second create used to fail with a conflict
// that blamed a display name nobody had taken.
func TestCreateIssueStatusDisambiguatesCollidingSlugs(t *testing.T) {
seedTestCatalog(t)
create := func(t *testing.T, name string) IssueStatusResponse {
t.Helper()
var created IssueStatusResponse
testutil.Call(t, testHandler.CreateIssueStatus,
newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": name, "category": issuestatus.Todo, "color": "#123456",
})).Want(http.StatusCreated).JSON(&created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
})
return created
}
if got := create(t, "待客户 Zzreview").Key; got != "zzreview" {
t.Fatalf("first key = %q, want %q", got, "zzreview")
}
if got := create(t, "待供应商 Zzreview").Key; got != "zzreview_2" {
t.Errorf("colliding key = %q, want %q", got, "zzreview_2")
}
}
// TestDerivedKeyAvoidsAnArchivedKey pins the storage constraint that makes the
// "taken" set wider than the visible catalog: idx_issue_status_workspace_key is
// NOT partial, so an archived status still owns its key and handing it out
// again would fail on insert.
func TestDerivedKeyAvoidsAnArchivedKey(t *testing.T) {
entry := createTestCustomStatus(t, "zzarchived", issuestatus.Todo)
if _, err := testHandler.Queries.ArchiveIssueStatusEntry(context.Background(), db.ArchiveIssueStatusEntryParams{
ID: entry.ID,
WorkspaceID: parseUUID(testWorkspaceID),
}); err != nil {
t.Fatalf("archive: %v", err)
}
var created IssueStatusResponse
testutil.Call(t, testHandler.CreateIssueStatus,
newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": "Zzarchived", "category": issuestatus.Todo, "color": "#123456",
})).Want(http.StatusCreated).JSON(&created)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
})
if created.Key == "zzarchived" {
t.Fatal("derivation reused an archived status's key")
}
if created.Key != "zzarchived_2" {
t.Errorf("derived key = %q, want %q", created.Key, "zzarchived_2")
}
}
// TestUnknownStatusErrorNamesCustomStatuses covers the other half of what makes
// a derived key usable: on its own `in_review_2` says nothing, so the error a
// caller gets after writing a bad status has to carry the display name or there
// is no way to find the status they were told to use. (MUL-6749)
func TestUnknownStatusErrorNamesCustomStatuses(t *testing.T) {
seedTestCatalog(t)
entry, err := testHandler.Queries.CreateIssueStatusEntry(context.Background(), db.CreateIssueStatusEntryParams{
WorkspaceID: parseUUID(testWorkspaceID),
Key: "in_review_9",
Name: "客户确认",
Description: "",
Category: issuestatus.InReview,
Color: "#123456",
})
if err != nil {
t.Fatalf("create custom status: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, entry.ID)
})
rec := httptest.NewRecorder()
testHandler.CreateIssue(rec, newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "unknown status error body",
"status": "not_a_status",
}))
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, "in_review_9 (客户确认)") {
t.Errorf("error body does not pair the key with its name: %s", body)
}
// Built-ins stay bare — clients localize those from the key, so echoing the
// seeded English name would be the one string a Chinese workspace ignores.
if strings.Contains(body, "todo (Todo)") {
t.Errorf("built-in statuses should be listed as bare keys: %s", body)
}
}
// TestIssueResponseCarriesCustomStatusName pins the field an agent reads an
// issue through. `status` alone is a bare handle, and a derived key carries no
// meaning, so the display name travels beside it. (MUL-6749)
func TestIssueResponseCarriesCustomStatusName(t *testing.T) {
seedTestCatalog(t)
entry, err := testHandler.Queries.CreateIssueStatusEntry(context.Background(), db.CreateIssueStatusEntryParams{
WorkspaceID: parseUUID(testWorkspaceID),
Key: "in_review_8",
Name: "客户确认",
Description: "",
Category: issuestatus.InReview,
Color: "#123456",
})
if err != nil {
t.Fatalf("create custom status: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, entry.ID)
})
var custom IssueResponse
testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "custom status name", "status": "in_review_8",
})).Want(http.StatusCreated).JSON(&custom)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(custom.ID))
})
if custom.StatusName != "客户确认" {
t.Errorf("status_name = %q, want %q", custom.StatusName, "客户确认")
}
if custom.StatusCategory != issuestatus.InReview {
t.Errorf("status_category = %q, want %q", custom.StatusCategory, issuestatus.InReview)
}
// A built-in carries no name: every client renders those from the key
// through i18n, so the seeded English one would be noise at best.
var builtIn IssueResponse
testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "built-in status name", "status": issuestatus.Todo,
})).Want(http.StatusCreated).JSON(&builtIn)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(builtIn.ID))
})
if builtIn.StatusName != "" {
t.Errorf("built-in status_name = %q, want it empty", builtIn.StatusName)
}
}
// TestExplicitKeyCreateAlsoTakesTheCatalogLock closes the half-open race found
// in review of MUL-6749. Derivation reads the catalog and then inserts; if a
// create that supplies its OWN key were allowed to skip the lock, it could land
// on the key the derive just chose in exactly that gap. The derive would then
// fail on the unique index and return 409 to a settings form that has no key
// field — the same dead end this issue exists to remove.
//
// The assertion is that an explicit-key create PARKS while the lock is held.
// Before the fix it completed immediately.
func TestExplicitKeyCreateAlsoTakesTheCatalogLock(t *testing.T) {
seedTestCatalog(t)
ctx := context.Background()
tx, err := testPool.Begin(ctx)
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback(ctx)
if _, err := tx.Exec(ctx,
`SELECT pg_advisory_xact_lock(hashtextextended($1::uuid::text || ':issue_status', 0))`,
parseUUID(testWorkspaceID)); err != nil {
t.Fatalf("take exclusive lock: %v", err)
}
done := make(chan *httptest.ResponseRecorder, 1)
go func() {
rec := httptest.NewRecorder()
testHandler.CreateIssueStatus(rec, newRequest(http.MethodPost, "/api/issue-statuses", map[string]any{
"name": "Zzlockprobe", "key": "zzlockprobe", "category": issuestatus.Todo, "color": "#123456",
}))
done <- rec
}()
select {
case rec := <-done:
t.Fatalf("an explicit-key create completed (%d) while the catalog lock was held; "+
"it can still insert between a derived create's catalog read and its insert", rec.Code)
case <-time.After(400 * time.Millisecond):
// Parked on the lock, which is the point.
}
if err := tx.Commit(ctx); err != nil {
t.Fatalf("release lock: %v", err)
}
select {
case rec := <-done:
if rec.Code != http.StatusCreated {
t.Fatalf("explicit-key create after the lock released: %d %s", rec.Code, rec.Body.String())
}
var created IssueStatusResponse
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatalf("decode: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, parseUUID(created.ID))
})
case <-time.After(10 * time.Second):
t.Fatal("explicit-key create never completed after the lock released")
}
}
// TestConcurrentDerivedCreatesNeverConflict is the behavioral half of the same
// fix: whatever else is writing the catalog, a create that supplied no key must
// never come back 409. A caller who typed a key CAN legitimately be told it is
// taken; a caller who typed only a display name has nothing to correct.
//
// Every result carries WHICH kind of writer produced it. Counting successes
// without that — the first version of this test did — lets an explicit writer's
// 201 stand in for a derived writer's 409 and passes on exactly the regression
// it exists to catch.
//
// This is a probabilistic net, not a proof: the window it hunts for is the few
// microseconds between a derive's catalog read and its insert, and in-process
// it does not reliably open. TestExplicitKeyCreateAlsoTakesTheCatalogLock is
// the deterministic guard on the lock itself — this one guards the OUTCOME, so
// that any future path which lets a keyless create conflict shows up here even
// if the lock is still nominally taken. Do not delete one for the other.
func TestConcurrentDerivedCreatesNeverConflict(t *testing.T) {
seedTestCatalog(t)
const derivedWriters = 6
const rounds = 4
type result struct {
derived bool
label string
code int
key string
body string
}
// Every derived name must be ALL non-ASCII, or it slugs to something and
// never reaches the fallback the test is about: "客户确认0-1" keeps its ASCII
// digits and derives `0_1`, which contends with nothing. Uniqueness comes
// from a distinct base per writer and a repeated character per round, so the
// names stay collision-free without smuggling in a digit.
bases := []string{"客户确认", "供应商确认", "财务确认", "法务确认", "安全确认", "运营确认"}
if len(bases) != derivedWriters {
t.Fatalf("need one non-ASCII base per derived writer, got %d for %d", len(bases), derivedWriters)
}
for round := range rounds {
t.Run(fmt.Sprintf("round-%d", round), func(t *testing.T) {
results := make(chan result, derivedWriters+2)
start := make(chan struct{})
var wg sync.WaitGroup
post := func(derived bool, label string, body map[string]any) {
defer wg.Done()
<-start
rec := httptest.NewRecorder()
testHandler.CreateIssueStatus(rec, newRequest(http.MethodPost, "/api/issue-statuses", body))
var created IssueStatusResponse
json.Unmarshal(rec.Body.Bytes(), &created)
results <- result{derived, label, rec.Code, created.Key, rec.Body.String()}
}
// Admins naming a status in a non-Latin script, all in one category,
// so every one of them derives from the same base.
for i := range derivedWriters {
name := bases[i] + strings.Repeat("确", round+1)
wg.Add(1)
go post(true, name, map[string]any{
"name": name, "category": issuestatus.Blocked, "color": "#123456",
})
}
// Explicit writers aiming straight at the ordinals the derives want.
for _, key := range []string{"blocked_2", "blocked_3"} {
wg.Add(1)
go post(false, key, map[string]any{
"name": fmt.Sprintf("Zz %s %d", key, round), "key": key,
"category": issuestatus.Blocked, "color": "#123456",
})
}
close(start)
wg.Wait()
close(results)
t.Cleanup(func() {
testPool.Exec(context.Background(),
`DELETE FROM issue_status WHERE workspace_id = $1 AND category = 'blocked' AND is_system = FALSE`,
parseUUID(testWorkspaceID))
})
seen := map[string]bool{}
derivedSucceeded := 0
for r := range results {
switch {
case r.derived && r.code != http.StatusCreated:
// The regression this test exists for: a writer with no key
// field to correct was told to correct one.
t.Errorf("derived create %q returned %d, want 201 — a create with no key must never conflict: %s",
r.label, r.code, r.body)
continue
case !r.derived && r.code != http.StatusCreated && r.code != http.StatusConflict:
t.Errorf("explicit create %q returned %d, want 201 or 409: %s", r.label, r.code, r.body)
continue
case r.code == http.StatusConflict:
// Only reachable for an explicit writer, by the branch above.
if !strings.Contains(r.body, "already exists") {
t.Errorf("unexpected 409 body for %q: %s", r.label, r.body)
}
continue
}
if seen[r.key] {
t.Errorf("two statuses were created with the same key %q", r.key)
}
seen[r.key] = true
if issuestatus.IsBuiltIn(r.key) {
t.Errorf("a custom status shadowed the built-in key %q", r.key)
}
if r.derived {
// Pins the PREMISE: a derived name that slugs to anything at
// all takes the slug path and contends with nothing, which
// would leave this test green while exercising nothing.
if !strings.HasPrefix(r.key, issuestatus.Blocked+"_") {
t.Errorf("derived create %q produced key %q; the name must slug to nothing "+
"so it lands on the <category>_<n> fallback these writers contend for",
r.label, r.key)
}
derivedSucceeded++
}
}
if derivedSucceeded != derivedWriters {
t.Errorf("%d of %d DERIVED creates succeeded", derivedSucceeded, derivedWriters)
}
})
}
}
// TestCustomStatusPayloadsAgreeAcrossRenderings pins the contract
// TestIssueToMap_KeysMatchIssueResponse cannot reach with a built-in fixture:
// an issue on a CUSTOM status must describe itself identically whether it
// arrives over HTTP or on a background event. A field present in one and blank
// in the other reads back undefined depending on which entry point produced the
// issue. (MUL-6749)
func TestCustomStatusPayloadsAgreeAcrossRenderings(t *testing.T) {
ctx := context.Background()
seedTestCatalog(t)
entry, err := testHandler.Queries.CreateIssueStatusEntry(ctx, db.CreateIssueStatusEntryParams{
WorkspaceID: parseUUID(testWorkspaceID),
Key: "in_review_7",
Name: "客户确认",
Description: "",
Category: issuestatus.InReview,
Color: "#123456",
})
if err != nil {
t.Fatalf("create custom status: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue_status WHERE id = $1`, entry.ID)
})
var fromHTTP IssueResponse
testutil.Call(t, testHandler.CreateIssue,
newRequest(http.MethodPost, "/api/issues", map[string]any{
"title": "payload parity", "status": "in_review_7",
})).Want(http.StatusCreated).JSON(&fromHTTP)
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, parseUUID(fromHTTP.ID))
})
issue, err := testHandler.Queries.GetIssue(ctx, parseUUID(fromHTTP.ID))
if err != nil {
t.Fatalf("reload issue: %v", err)
}
fromEvent := service.IssueToMapResolved(ctx, testHandler.Queries, issue, "MUL")
for field, want := range map[string]string{
"status": "in_review_7",
"status_category": issuestatus.InReview,
"status_name": "客户确认",
} {
got, ok := fromEvent[field].(string)
if !ok {
t.Errorf("event payload is missing %q; clients treat it as a complete issue", field)
continue
}
if got != want {
t.Errorf("event %s = %q, want %q", field, got, want)
}
}
if fromHTTP.StatusName != fromEvent["status_name"] {
t.Errorf("status_name differs by entry point: HTTP %q, event %q",
fromHTTP.StatusName, fromEvent["status_name"])
}
if fromHTTP.StatusCategory != fromEvent["status_category"] {
t.Errorf("status_category differs by entry point: HTTP %q, event %q",
fromHTTP.StatusCategory, fromEvent["status_category"])
}
}