Files
multica/server/internal/handler/issue_table_query_test.go
Nicolas Fléron 5336391978 MUL-7299: feat(issues): filter issues by the parent project's status (#8321)
Adds "Project status" as its own issue filter dimension, next to "Project":
pick "In Progress" once instead of ticking every active project, and the
result follows project lifecycle changes instead of freezing a project-id
snapshot.

Server side extends the existing issue table filter protocol with an
optional, validated `project_statuses`, filtered through a parameterized,
workspace-bound EXISTS on `project`. No migration and no new index — the
predicate resolves through `project_pkey`, and the planner falls back to the
existing `idx_issue_project_status`.

Client side wires it as a normal dimension: filter menu, chip, reset,
saved views and Gantt/Swimlane, with the project catalog treated as
"cannot answer yet" rather than "no filter" while it loads. Project
status changes, deletes and realtime project events invalidate the issue
table cache, since issue rows themselves do not change.

Default off; closing the filter reverts the behavior.
2026-09-18 15:27:21 +08:00

1606 lines
57 KiB
Go

package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/multica-ai/multica/server/internal/testutil"
)
type issueTableEnrichmentFailTxStarter struct {
inner txStarter
labelCalls *int
tableQueryCalls *int
facetQueryCalls *int
rowQuerySQL *string
groupQuerySQL *string
}
func (s issueTableEnrichmentFailTxStarter) Begin(ctx context.Context) (pgx.Tx, error) {
tx, err := s.inner.Begin(ctx)
if err != nil {
return nil, err
}
return &issueTableEnrichmentFailTx{
Tx: tx,
labelCalls: s.labelCalls,
tableQueryCalls: s.tableQueryCalls,
facetQueryCalls: s.facetQueryCalls,
rowQuerySQL: s.rowQuerySQL,
groupQuerySQL: s.groupQuerySQL,
}, nil
}
type issueTableEnrichmentFailTx struct {
pgx.Tx
labelCalls *int
tableQueryCalls *int
facetQueryCalls *int
rowQuerySQL *string
groupQuerySQL *string
}
func (tx *issueTableEnrichmentFailTx) recordTableQuery(sql string) {
if tx.tableQueryCalls != nil {
if strings.Contains(sql, "page AS MATERIALIZED (") ||
strings.Contains(sql, "SELECT COUNT(*)::bigint FROM issue i WHERE") {
*tx.tableQueryCalls = *tx.tableQueryCalls + 1
}
}
if tx.facetQueryCalls != nil && strings.Contains(sql, "GROUP BY GROUPING SETS") {
*tx.facetQueryCalls = *tx.facetQueryCalls + 1
}
if tx.rowQuerySQL != nil && strings.Contains(sql, "page AS MATERIALIZED (") {
*tx.rowQuerySQL = sql
}
if tx.groupQuerySQL != nil && strings.Contains(sql, "WITH grouped AS (") {
*tx.groupQuerySQL = sql
}
}
func (tx *issueTableEnrichmentFailTx) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) {
tx.recordTableQuery(sql)
if strings.Contains(sql, "ListLabelsForIssues") {
*tx.labelCalls = *tx.labelCalls + 1
// A real PostgreSQL statement error poisons the transaction until
// rollback. Before enrichment moved after Commit, this turned the
// otherwise successful row window into a 500.
_, err := tx.Tx.Exec(ctx, "SELECT * FROM issue_table_missing_enrichment_relation")
return nil, err
}
return tx.Tx.Query(ctx, sql, args...)
}
func (tx *issueTableEnrichmentFailTx) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row {
tx.recordTableQuery(sql)
return tx.Tx.QueryRow(ctx, sql, args...)
}
func TestCanonicalIssueTableFingerprintNormalizesSetLikeArrays(t *testing.T) {
left := issueTableQuerySpec{
Scope: issueTableScope{Kind: "workspace", AssigneeTypes: []string{"agent", "member", "agent"}},
Filters: issueTableFiltersRequest{
Statuses: []string{"todo", "backlog", "todo"},
ProjectIDs: []string{"b", "a"},
ProjectStatuses: []string{"planned", "in_progress", "planned"},
},
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
}
right := issueTableQuerySpec{
Scope: issueTableScope{Kind: "workspace", AssigneeTypes: []string{"member", "agent"}},
Filters: issueTableFiltersRequest{
Statuses: []string{"backlog", "todo"},
ProjectIDs: []string{"a", "b"},
ProjectStatuses: []string{"in_progress", "planned"},
},
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
}
leftFingerprint, err := canonicalIssueTableFingerprint("workspace-1", left)
if err != nil {
t.Fatal(err)
}
rightFingerprint, err := canonicalIssueTableFingerprint("workspace-1", right)
if err != nil {
t.Fatal(err)
}
if leftFingerprint != rightFingerprint {
t.Fatalf("equivalent table queries produced different fingerprints: %s != %s", leftFingerprint, rightFingerprint)
}
}
func TestCanonicalIssueTableFingerprintBindsWorkspace(t *testing.T) {
spec := issueTableQuerySpec{
Scope: issueTableScope{Kind: "workspace"},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
left, err := canonicalIssueTableFingerprint("workspace-1", spec)
if err != nil {
t.Fatal(err)
}
right, err := canonicalIssueTableFingerprint("workspace-2", spec)
if err != nil {
t.Fatal(err)
}
if left == right {
t.Fatal("equivalent queries in different workspaces produced the same fingerprint")
}
}
func TestIssueTableExplicitEmptyAssigneesMatchesNone(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
base := issueTableQuerySpec{
Scope: issueTableScope{Kind: "workspace"},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
withEmpty := base
withEmpty.Filters.Assignees = []issueTableActorRef{}
unfilteredFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, base)
if err != nil {
t.Fatal(err)
}
emptyFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, withEmpty)
if err != nil {
t.Fatal(err)
}
if unfilteredFingerprint == emptyFingerprint {
t.Fatal("explicit empty assignees must not share the unfiltered cursor fingerprint")
}
w := httptest.NewRecorder()
compiled, ok := testHandler.compileIssueTableQuery(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
withEmpty,
)
if !ok {
t.Fatalf("compile failed: %d %s", w.Code, w.Body.String())
}
if !strings.Contains(compiled.where, "FALSE") {
t.Fatalf("explicit empty assignees predicate = %q, want FALSE", compiled.where)
}
}
func TestIssueTableProjectScopeAssigneeTypes(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
spec := issueTableQuerySpec{
Scope: issueTableScope{
Kind: "project",
ProjectID: "00000000-0000-0000-0000-000000000001",
AssigneeTypes: []string{"agent", "squad"},
},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
w := httptest.NewRecorder()
compiled, ok := testHandler.compileIssueTableQuery(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
spec,
)
if !ok {
t.Fatalf("compile failed: %d %s", w.Code, w.Body.String())
}
if !strings.Contains(compiled.where, "i.project_id") {
t.Fatalf("project predicate missing: %q", compiled.where)
}
if !strings.Contains(compiled.where, "i.assignee_type = ANY") {
t.Fatalf("assignee-type narrowing missing on project scope: %q", compiled.where)
}
bad := spec
bad.Scope.AssigneeTypes = []string{"martian"}
w = httptest.NewRecorder()
if _, ok := testHandler.compileIssueTableQuery(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
bad,
); ok {
t.Fatal("invalid assignee_types must be rejected on project scope")
}
}
func TestIssueTableWorkingIssueIDsAreExplicitAndAssigneeIndependent(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
base := issueTableQuerySpec{
Scope: issueTableScope{Kind: "workspace"},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
withEmpty := base
withEmpty.Filters.WorkingIssueIDs = []string{}
unfilteredFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, base)
if err != nil {
t.Fatal(err)
}
emptyFingerprint, err := canonicalIssueTableFingerprint(testWorkspaceID, withEmpty)
if err != nil {
t.Fatal(err)
}
if unfilteredFingerprint == emptyFingerprint {
t.Fatal("explicit empty working_issue_ids must not share the unfiltered cursor fingerprint")
}
w := httptest.NewRecorder()
compiled, ok := testHandler.compileIssueTableQuery(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
withEmpty,
)
if !ok {
t.Fatalf("compile empty filter: %d %s", w.Code, w.Body.String())
}
if !strings.Contains(compiled.where, "FALSE") {
t.Fatalf("explicit empty working_issue_ids predicate = %q, want FALSE", compiled.where)
}
withIssue := base
withIssue.Filters.WorkingIssueIDs = []string{
"00000000-0000-4000-8000-000000000001",
}
w = httptest.NewRecorder()
compiled, ok = testHandler.compileIssueTableQuery(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
withIssue,
)
if !ok {
t.Fatalf("compile issue filter: %d %s", w.Code, w.Body.String())
}
if !strings.Contains(compiled.where, "i.id = ANY(") {
t.Errorf("working-issue predicate = %q, want issue-id membership", compiled.where)
}
if strings.Contains(compiled.where, "i.assignee_id") {
t.Fatalf("working-issue predicate must not filter issue assignees: %q", compiled.where)
}
}
func TestIssueTableWorkingIssueProjectionMatchesTaskIssuesNotAssignees(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
workingAgentID := createHandlerTestAgent(t, "table-working-task-agent", []byte(`{}`))
otherAgentID := createHandlerTestAgent(t, "table-other-working-agent", []byte(`{}`))
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 3
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
insertIssue := func(title string, number int, assigneeType string, assigneeID any) string {
t.Helper()
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
assignee_type, assignee_id, position, number
)
VALUES ($1, $2, 'todo', 'none', 'member', $3, $4, $5, $6, $7)
RETURNING id
`,
testWorkspaceID,
title,
testUserID,
assigneeType,
assigneeID,
number,
number,
).Scan(&issueID); err != nil {
t.Fatalf("insert issue %q: %v", title, err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
return issueID
}
assignedOnlyIssueID := insertIssue(
"assigned to working agent but not being edited",
finalNumber-2,
"agent",
workingAgentID,
)
editedIssueID := insertIssue(
"being edited by working agent but assigned to member",
finalNumber-1,
"member",
testUserID,
)
otherAgentIssueID := insertIssue(
"being edited by another agent",
finalNumber,
"agent",
workingAgentID,
)
createHandlerTestTaskForAgentOnIssue(t, workingAgentID, editedIssueID)
createHandlerTestTaskForAgentOnIssue(t, otherAgentID, otherAgentIssueID)
workingRecorder := httptest.NewRecorder()
testHandler.ListWorkspaceWorkingAgents(
workingRecorder,
newRequest(http.MethodGet, "/api/working-agents?type=issue", nil),
)
if workingRecorder.Code != http.StatusOK {
t.Fatalf(
"list working agents status = %d: %s",
workingRecorder.Code,
workingRecorder.Body.String(),
)
}
var workingAgents []WorkspaceWorkingAgent
if err := json.NewDecoder(workingRecorder.Body).Decode(&workingAgents); err != nil {
t.Fatalf("decode working agents: %v", err)
}
workingIssueIDs := make([]string, 0, 2)
for _, agent := range workingAgents {
if agent.ID == workingAgentID || agent.ID == otherAgentID {
workingIssueIDs = append(workingIssueIDs, agent.IssueIDs...)
}
}
listRows := func(issueIDs []string) issueTableRowsResponse {
t.Helper()
recorder := httptest.NewRecorder()
testHandler.ListIssueTableRows(
recorder,
newRequest(http.MethodPost, "/api/issues/table/rows", map[string]any{
"query": map[string]any{
"scope": map[string]any{"kind": "workspace"},
"filters": map[string]any{
// A map intentionally preserves [] in JSON. Marshaling
// issueTableFiltersRequest directly would apply its
// omitempty tag and turn the match-none case into no filter.
"working_issue_ids": issueIDs,
},
"sort": map[string]any{
"field": "position",
"direction": "asc",
},
},
"group": map[string]any{"kind": "none"},
"hierarchy": map[string]any{"enabled": false},
"page": map[string]any{"limit": 10},
}),
)
if recorder.Code != http.StatusOK {
t.Fatalf("list rows status = %d: %s", recorder.Code, recorder.Body.String())
}
var response issueTableRowsResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode rows: %v", err)
}
return response
}
response := listRows(workingIssueIDs)
if response.Total != 2 || len(response.Rows) != 2 {
t.Fatalf("working rows total=%d rows=%d, want two", response.Total, len(response.Rows))
}
gotIssueIDs := make(map[string]struct{}, len(response.Rows))
for _, row := range response.Rows {
gotIssueIDs[row.Issue.ID] = struct{}{}
}
if _, ok := gotIssueIDs[editedIssueID]; !ok {
t.Errorf("missing issue edited by selected working agent: %s", editedIssueID)
}
if _, ok := gotIssueIDs[otherAgentIssueID]; !ok {
t.Errorf("missing issue edited by other working agent: %s", otherAgentIssueID)
}
if _, ok := gotIssueIDs[assignedOnlyIssueID]; ok {
t.Errorf("included assigned-only issue without a running task: %s", assignedOnlyIssueID)
}
empty := listRows([]string{})
if empty.Total != 0 || len(empty.Rows) != 0 {
t.Fatalf("explicit empty working-issue filter returned total=%d rows=%d", empty.Total, len(empty.Rows))
}
}
func TestIssueTableCursorRejectsAnotherQuery(t *testing.T) {
groupKey := "status:todo"
cursor := issueTableCursor{
Version: 1,
QueryFingerprint: "sha256:old",
GroupKey: &groupKey,
}
w := httptest.NewRecorder()
if issueTableCursorMatches(w, &cursor, "sha256:new", &groupKey, nil) {
t.Fatal("cursor from another query unexpectedly matched")
}
if w.Code != http.StatusConflict {
t.Fatalf("status = %d, want %d", w.Code, http.StatusConflict)
}
}
func TestIssueTablePositionCursorIncludesIndexableLowerBound(t *testing.T) {
cursorValue := "90000"
cursor := issueTableCursor{
SortValue: &cursorValue,
RowCreatedAt: "2026-01-01T00:00:00Z",
RowID: "00000000-0000-4000-8000-000000000001",
}
args := make([]any, 0, 3)
predicate, ok := (resolvedIssueTableSort{
expression: "i.position",
direction: "asc",
castType: "double precision",
}).cursorPredicate(httptest.NewRecorder(), &cursor, func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
})
if !ok {
t.Fatal("valid position cursor was rejected")
}
if !strings.Contains(predicate, "i.position >= $3::double precision") {
t.Fatalf("position cursor is missing its indexable lower bound: %s", predicate)
}
}
func TestIssueTableLastActivityDefaultsToIndexedOrder(t *testing.T) {
w := httptest.NewRecorder()
sort, ok := testHandler.issueTableOrderBy(
w,
newRequest(http.MethodPost, "/api/issues/table/rows", nil),
parseUUID(testWorkspaceID),
issueTableSortRequest{Field: "last_activity"},
func(any) string { return "$1" },
)
if !ok {
t.Fatalf("last_activity sort rejected: status=%d body=%s", w.Code, w.Body.String())
}
if got, want := sort.orderBy(), "i.last_activity_at DESC NULLS LAST, i.id DESC"; got != want {
t.Fatalf("orderBy = %q, want %q", got, want)
}
sortValue := "2026-08-19T05:04:03.123456Z"
cursor := issueTableCursor{
SortValue: &sortValue,
RowID: "00000000-0000-4000-8000-000000000001",
}
args := make([]any, 0, 2)
predicate, ok := sort.cursorPredicate(w, &cursor, func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
})
if !ok {
t.Fatalf("valid last_activity cursor rejected: status=%d body=%s", w.Code, w.Body.String())
}
if strings.Contains(predicate, "created_at") {
t.Fatalf("last_activity cursor unexpectedly uses created_at tie-break: %s", predicate)
}
if !strings.Contains(predicate, "i.id < $1::uuid") {
t.Fatalf("last_activity cursor is missing id tie-break: %s", predicate)
}
}
func TestIssueTableGroupIdentityBindsIncludeEmpty(t *testing.T) {
withoutEmpty := issueTableGroupIdentity(issueTableGroupSpec{
Kind: "property",
PropertyID: "00000000-0000-4000-8000-000000000001",
})
withEmpty := issueTableGroupIdentity(issueTableGroupSpec{
Kind: "property",
PropertyID: "00000000-0000-4000-8000-000000000001",
IncludeEmpty: true,
})
if withoutEmpty == withEmpty {
t.Fatalf("include-empty property cursors share an identity: %q", withEmpty)
}
}
func TestIssueTableCompoundCellKeyResolvesPrimaryAndStatus(t *testing.T) {
primary := resolvedIssueTableGroup{kind: "parent"}
compound := resolvedIssueTableGroup{kind: "compound", primary: &primary}
key := compoundCellGroupKey(
"parent:00000000-0000-4000-8000-000000000001",
"todo",
false,
)
args := make([]any, 0, 2)
predicate, ok := compound.predicate(
httptest.NewRecorder(),
key,
func(value any) string {
args = append(args, value)
return fmt.Sprintf("$%d", len(args))
},
)
if !ok {
t.Fatal("valid compound cell key was rejected")
}
if !strings.Contains(predicate, "i.parent_issue_id = $1::uuid") ||
!strings.Contains(predicate, "i.status = $2::text") ||
len(args) != 2 || args[1] != "todo" {
t.Fatalf("compound cell predicate lost a dimension: %s args=%#v", predicate, args)
}
}
func TestIssueTableRowsCommitsBeforeBestEffortEnrichment(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, fmt.Sprintf("Table enrichment %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var issueNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 1
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&issueNumber); err != nil {
t.Fatalf("reserve issue number: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
VALUES ($1, 'table-enrichment', 'todo', 'none', 'member', $2, 1, $3, $4)
`, testWorkspaceID, testUserID, issueNumber, projectID); err != nil {
t.Fatalf("seed issue: %v", err)
}
labelCalls := 0
tableQueryCalls := 0
rowQuerySQL := ""
handler := *testHandler
handler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
labelCalls: &labelCalls,
tableQueryCalls: &tableQueryCalls,
rowQuerySQL: &rowQuerySQL,
}
recorder := httptest.NewRecorder()
handler.ListIssueTableRows(recorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
},
Group: issueTableGroupSpec{Kind: "none"},
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 10},
}))
if recorder.Code != http.StatusOK {
t.Fatalf("rows status = %d: %s", recorder.Code, recorder.Body.String())
}
if labelCalls != 0 {
t.Fatalf("best-effort labels ran inside snapshot transaction %d times", labelCalls)
}
var response issueTableRowsResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode rows: %v", err)
}
if len(response.Rows) != 1 {
t.Fatalf("rows = %d, want 1", len(response.Rows))
}
if response.Total != 1 || response.BranchTotal != 1 {
t.Fatalf("unexpected root counts: total=%d branch_total=%d", response.Total, response.BranchTotal)
}
if tableQueryCalls != 2 {
t.Fatalf("ungrouped root head executed %d table queries, want 2", tableQueryCalls)
}
if !strings.Contains(rowQuerySQL, "WITH page AS MATERIALIZED") ||
strings.Contains(rowQuerySQL, "membership AS") ||
strings.Contains(rowQuerySQL, "FROM membership child") {
t.Fatalf("flat rows query must page directly without hierarchy work:\n%s", rowQuerySQL)
}
}
func TestIssueTableStatusGroupingOverOneThousandRows(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, fmt.Sprintf("Server table grouping %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 1001
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
firstNumber := finalNumber - 1000
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
SELECT $1, 'server-table-' || n::text,
CASE WHEN n <= 501 THEN 'todo' ELSE 'done' END,
'none', 'member', $2, n::double precision,
$3 + n - 1, $4
FROM generate_series(1, 1001) AS n
`, testWorkspaceID, testUserID, firstNumber, projectID); err != nil {
t.Fatalf("seed issues: %v", err)
}
query := issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Filters: issueTableFiltersRequest{},
Sort: issueTableSortRequest{Field: "title", Direction: "asc"},
}
w := httptest.NewRecorder()
testHandler.ListIssueTableGroups(w, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
Page: issueTablePageRequest{Limit: 100},
}))
if w.Code != http.StatusOK {
t.Fatalf("groups status = %d: %s", w.Code, w.Body.String())
}
var groups issueTableGroupsResponse
if err := json.NewDecoder(w.Body).Decode(&groups); err != nil {
t.Fatalf("decode groups: %v", err)
}
if groups.Total != 1001 {
t.Fatalf("total = %d, want 1001", groups.Total)
}
counts := map[string]int64{}
for _, group := range groups.Groups {
counts[group.Key] = group.Count
}
if counts["status:todo"] != 501 || counts["status:done"] != 500 {
t.Fatalf("unexpected group counts: %#v", counts)
}
firstGroupPageRecorder := httptest.NewRecorder()
testHandler.ListIssueTableGroups(firstGroupPageRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
Page: issueTablePageRequest{Limit: 1},
}))
var firstGroupPage issueTableGroupsResponse
if firstGroupPageRecorder.Code != http.StatusOK {
t.Fatalf("first group page status = %d: %s", firstGroupPageRecorder.Code, firstGroupPageRecorder.Body.String())
}
if err := json.NewDecoder(firstGroupPageRecorder.Body).Decode(&firstGroupPage); err != nil {
t.Fatalf("decode first group page: %v", err)
}
if len(firstGroupPage.Groups) != 1 || firstGroupPage.NextCursor == nil {
t.Fatalf("unexpected first group page: %#v", firstGroupPage)
}
secondGroupPageRecorder := httptest.NewRecorder()
testHandler.ListIssueTableGroups(secondGroupPageRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
Page: issueTablePageRequest{Limit: 1, Cursor: firstGroupPage.NextCursor},
}))
var secondGroupPage issueTableGroupsResponse
if secondGroupPageRecorder.Code != http.StatusOK {
t.Fatalf("second group page status = %d: %s", secondGroupPageRecorder.Code, secondGroupPageRecorder.Body.String())
}
if err := json.NewDecoder(secondGroupPageRecorder.Body).Decode(&secondGroupPage); err != nil {
t.Fatalf("decode second group page: %v", err)
}
if len(secondGroupPage.Groups) != 1 || secondGroupPage.Groups[0].Key == firstGroupPage.Groups[0].Key || secondGroupPage.Total != 1001 {
t.Fatalf("group keyset pagination mismatch: first=%#v second=%#v", firstGroupPage, secondGroupPage)
}
groupKey := "status:todo"
labelCalls := 0
tableQueryCalls := 0
rowsHandler := *testHandler
rowsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
labelCalls: &labelCalls,
tableQueryCalls: &tableQueryCalls,
}
rowsRecorder := httptest.NewRecorder()
rowsHandler.ListIssueTableRows(rowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
GroupKey: &groupKey,
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 50},
}))
if rowsRecorder.Code != http.StatusOK {
t.Fatalf("rows status = %d: %s", rowsRecorder.Code, rowsRecorder.Body.String())
}
var rows issueTableRowsResponse
if err := json.NewDecoder(rowsRecorder.Body).Decode(&rows); err != nil {
t.Fatalf("decode rows: %v", err)
}
if rows.Total != 0 || rows.BranchTotal != 50 || len(rows.Rows) != 50 || rows.NextCursor == nil {
t.Fatalf("unexpected rows page: total=%d branch_total=%d rows=%d cursor=%v", rows.Total, rows.BranchTotal, len(rows.Rows), rows.NextCursor)
}
if tableQueryCalls != 1 {
t.Fatalf("grouped root head executed %d table queries, want 1", tableQueryCalls)
}
firstPageIDs := make(map[string]struct{}, len(rows.Rows))
for _, row := range rows.Rows {
firstPageIDs[row.Issue.ID] = struct{}{}
}
secondRowsRecorder := httptest.NewRecorder()
rowsHandler.ListIssueTableRows(secondRowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
GroupKey: &groupKey,
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 50, Cursor: rows.NextCursor},
}))
if secondRowsRecorder.Code != http.StatusOK {
t.Fatalf("second rows status = %d: %s", secondRowsRecorder.Code, secondRowsRecorder.Body.String())
}
var secondRows issueTableRowsResponse
if err := json.NewDecoder(secondRowsRecorder.Body).Decode(&secondRows); err != nil {
t.Fatalf("decode second rows: %v", err)
}
if secondRows.Total != 0 || secondRows.BranchTotal != 50 || len(secondRows.Rows) != 50 {
t.Fatalf("unexpected grouped continuation: total=%d branch_total=%d rows=%d", secondRows.Total, secondRows.BranchTotal, len(secondRows.Rows))
}
if tableQueryCalls != 2 {
t.Fatalf("grouped continuation executed %d cumulative table queries, want 2", tableQueryCalls)
}
for _, row := range secondRows.Rows {
if _, duplicate := firstPageIDs[row.Issue.ID]; duplicate {
t.Fatalf("keyset cursor repeated issue %s across pages", row.Issue.ID)
}
}
ungroupedRecorder := httptest.NewRecorder()
rowsHandler.ListIssueTableRows(ungroupedRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "none"},
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 50},
}))
if ungroupedRecorder.Code != http.StatusOK {
t.Fatalf("ungrouped rows status = %d: %s", ungroupedRecorder.Code, ungroupedRecorder.Body.String())
}
var ungroupedRows issueTableRowsResponse
if err := json.NewDecoder(ungroupedRecorder.Body).Decode(&ungroupedRows); err != nil {
t.Fatalf("decode ungrouped rows: %v", err)
}
if ungroupedRows.Total != 1001 || ungroupedRows.BranchTotal != 50 || len(ungroupedRows.Rows) != 50 || ungroupedRows.NextCursor == nil {
t.Fatalf("unexpected ungrouped root head: total=%d branch_total=%d rows=%d cursor=%v", ungroupedRows.Total, ungroupedRows.BranchTotal, len(ungroupedRows.Rows), ungroupedRows.NextCursor)
}
if tableQueryCalls != 4 {
t.Fatalf("ungrouped root head executed %d cumulative table queries, want 4", tableQueryCalls)
}
ungroupedNextRecorder := httptest.NewRecorder()
rowsHandler.ListIssueTableRows(ungroupedNextRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "none"},
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 50, Cursor: ungroupedRows.NextCursor},
}))
if ungroupedNextRecorder.Code != http.StatusOK {
t.Fatalf("ungrouped continuation status = %d: %s", ungroupedNextRecorder.Code, ungroupedNextRecorder.Body.String())
}
var ungroupedNext issueTableRowsResponse
if err := json.NewDecoder(ungroupedNextRecorder.Body).Decode(&ungroupedNext); err != nil {
t.Fatalf("decode ungrouped continuation: %v", err)
}
if ungroupedNext.Total != 0 || ungroupedNext.BranchTotal != 50 || len(ungroupedNext.Rows) != 50 {
t.Fatalf("unexpected ungrouped continuation: total=%d branch_total=%d rows=%d", ungroupedNext.Total, ungroupedNext.BranchTotal, len(ungroupedNext.Rows))
}
if tableQueryCalls != 5 {
t.Fatalf("ungrouped continuation executed %d cumulative table queries, want 5", tableQueryCalls)
}
for _, sortCase := range []issueTableSortRequest{
{Field: "status", Direction: "desc"},
{Field: "created_at", Direction: "desc"},
{Field: "due_date", Direction: "asc"},
} {
sortQuery := query
sortQuery.Sort = sortCase
fetchPage := func(cursor *string) issueTableRowsResponse {
t.Helper()
recorder := httptest.NewRecorder()
testHandler.ListIssueTableRows(recorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: sortQuery,
Group: issueTableGroupSpec{Kind: "status"},
GroupKey: &groupKey,
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 10, Cursor: cursor},
}))
if recorder.Code != http.StatusOK {
t.Fatalf("%s cursor page status = %d: %s", sortCase.Field, recorder.Code, recorder.Body.String())
}
var response issueTableRowsResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode %s cursor page: %v", sortCase.Field, err)
}
return response
}
first := fetchPage(nil)
if len(first.Rows) != 10 || first.NextCursor == nil {
t.Fatalf("%s first cursor page is incomplete", sortCase.Field)
}
seen := make(map[string]struct{}, len(first.Rows))
for _, row := range first.Rows {
seen[row.Issue.ID] = struct{}{}
}
second := fetchPage(first.NextCursor)
if len(second.Rows) != 10 {
t.Fatalf("%s second cursor page length = %d", sortCase.Field, len(second.Rows))
}
for _, row := range second.Rows {
if _, duplicate := seen[row.Issue.ID]; duplicate {
t.Fatalf("%s keyset cursor repeated issue %s", sortCase.Field, row.Issue.ID)
}
}
}
filteredQuery := query
filteredQuery.Filters.Statuses = []string{"todo"}
facetsRecorder := httptest.NewRecorder()
testHandler.ListIssueTableFacets(facetsRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
Query: filteredQuery,
Facets: []issueTableFacetSpec{{Kind: "status"}},
}))
if facetsRecorder.Code != http.StatusOK {
t.Fatalf("facets status = %d: %s", facetsRecorder.Code, facetsRecorder.Body.String())
}
var facets issueTableFacetsResponse
if err := json.NewDecoder(facetsRecorder.Body).Decode(&facets); err != nil {
t.Fatalf("decode facets: %v", err)
}
if facets.Total != 501 || len(facets.Facets) != 1 {
t.Fatalf("unexpected filtered facet response: total=%d facets=%d", facets.Total, len(facets.Facets))
}
facetCounts := map[string]int64{}
for _, value := range facets.Facets[0].Values {
facetCounts[value.Key] = value.Count
}
if facetCounts["todo"] != 501 || facetCounts["done"] != 500 {
t.Fatalf("status facet must ignore its own active filter: %#v", facetCounts)
}
includeTotal := false
facetCountQueries := 0
facetsHandler := *testHandler
facetsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
tableQueryCalls: &facetCountQueries,
}
noTotalRecorder := httptest.NewRecorder()
facetsHandler.ListIssueTableFacets(noTotalRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
Query: filteredQuery,
Facets: []issueTableFacetSpec{{Kind: "status"}},
IncludeTotal: &includeTotal,
}))
if noTotalRecorder.Code != http.StatusOK {
t.Fatalf("facets without total status = %d: %s", noTotalRecorder.Code, noTotalRecorder.Body.String())
}
var noTotal issueTableFacetsResponse
if err := json.NewDecoder(noTotalRecorder.Body).Decode(&noTotal); err != nil {
t.Fatalf("decode facets without total: %v", err)
}
if noTotal.Total != 0 || len(noTotal.Facets) != 1 {
t.Fatalf("unexpected facets without total: %#v", noTotal)
}
if facetCountQueries != 0 {
t.Fatalf("include_total=false executed %d total count queries, want 0", facetCountQueries)
}
batchFacetQueries := 0
batchTotalQueries := 0
batchHandler := *testHandler
batchHandler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
facetQueryCalls: &batchFacetQueries,
tableQueryCalls: &batchTotalQueries,
}
batchRecorder := httptest.NewRecorder()
batchHandler.ListIssueTableFacets(batchRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
Query: query,
Facets: []issueTableFacetSpec{
{Kind: "status"},
{Kind: "priority"},
{Kind: "assignee"},
{Kind: "creator"},
{Kind: "project"},
},
}))
if batchRecorder.Code != http.StatusOK {
t.Fatalf("batch facets status = %d: %s", batchRecorder.Code, batchRecorder.Body.String())
}
var batchResponse issueTableFacetsResponse
if err := json.NewDecoder(batchRecorder.Body).Decode(&batchResponse); err != nil {
t.Fatalf("decode batch facets: %v", err)
}
if batchResponse.Total != 1001 || len(batchResponse.Facets) != 5 {
t.Fatalf("unexpected batch facets response: total=%d facets=%d", batchResponse.Total, len(batchResponse.Facets))
}
if batchFacetQueries != 1 || batchTotalQueries != 0 {
t.Fatalf("batch facets executed grouping queries=%d total queries=%d, want 1 and 0", batchFacetQueries, batchTotalQueries)
}
batchCounts := make(map[string]map[string]int64, len(batchResponse.Facets))
for _, facet := range batchResponse.Facets {
counts := make(map[string]int64, len(facet.Values))
for _, value := range facet.Values {
counts[value.Key] = value.Count
}
batchCounts[facet.Kind] = counts
}
if batchCounts["status"]["todo"] != 501 || batchCounts["status"]["done"] != 500 ||
batchCounts["priority"]["none"] != 1001 ||
batchCounts["assignee"]["__none__"] != 1001 ||
batchCounts["creator"]["member:"+testUserID] != 1001 ||
batchCounts["project"][projectID] != 1001 {
t.Fatalf("unexpected batched facet counts: %#v", batchCounts)
}
mixedRecorder := httptest.NewRecorder()
testHandler.ListIssueTableFacets(mixedRecorder, newRequest("POST", "/api/issues/table/facets", issueTableFacetsRequest{
Query: filteredQuery,
Facets: []issueTableFacetSpec{{Kind: "status"}, {Kind: "priority"}},
}))
if mixedRecorder.Code != http.StatusOK {
t.Fatalf("mixed facets status = %d: %s", mixedRecorder.Code, mixedRecorder.Body.String())
}
var mixedResponse issueTableFacetsResponse
if err := json.NewDecoder(mixedRecorder.Body).Decode(&mixedResponse); err != nil {
t.Fatalf("decode mixed facets: %v", err)
}
if mixedResponse.Total != 501 || len(mixedResponse.Facets) != 2 ||
mixedResponse.Facets[0].Kind != "status" || mixedResponse.Facets[1].Kind != "priority" {
t.Fatalf("unexpected mixed facets response: %#v", mixedResponse)
}
mixedCounts := make(map[string]map[string]int64, len(mixedResponse.Facets))
for _, facet := range mixedResponse.Facets {
counts := make(map[string]int64, len(facet.Values))
for _, value := range facet.Values {
counts[value.Key] = value.Count
}
mixedCounts[facet.Kind] = counts
}
if mixedCounts["status"]["todo"] != 501 || mixedCounts["status"]["done"] != 500 ||
mixedCounts["priority"]["none"] != 501 {
t.Fatalf("mixed facets lost disjunctive semantics: %#v", mixedCounts)
}
}
func TestIssueTableAssigneeNamesResolveAfterGrouping(t *testing.T) {
ctx := context.Background()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, 'Server table assignee grouping')
RETURNING id
`, testWorkspaceID).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 2
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, assignee_type, assignee_id,
creator_type, creator_id, position, number, project_id
)
VALUES
($1, 'Assigned row', 'todo', 'none', 'member', $2, 'member', $2, 1, $3, $4),
($1, 'Unassigned row', 'todo', 'none', NULL, NULL, 'member', $2, 2, $3 + 1, $4)
`, testWorkspaceID, testUserID, finalNumber-1, projectID); err != nil {
t.Fatalf("seed issues: %v", err)
}
groupQuerySQL := ""
handler := *testHandler
handler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
groupQuerySQL: &groupQuerySQL,
}
recorder := httptest.NewRecorder()
handler.ListIssueTableGroups(recorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
},
Group: issueTableGroupSpec{Kind: "assignee"},
Page: issueTablePageRequest{Limit: 10},
}))
if recorder.Code != http.StatusOK {
t.Fatalf("groups status = %d: %s", recorder.Code, recorder.Body.String())
}
var response issueTableGroupsResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode groups: %v", err)
}
counts := make(map[string]int64, len(response.Groups))
for _, group := range response.Groups {
counts[group.Key] = group.Count
}
if counts["assignee:member:"+testUserID] != 1 || counts["assignee:unassigned"] != 1 {
t.Fatalf("unexpected assignee groups: %#v", counts)
}
if response.Groups[0].Key != "assignee:member:"+testUserID ||
response.Groups[len(response.Groups)-1].Key != "assignee:unassigned" {
t.Fatalf("assignee groups are not actor-priority ordered: %#v", response.Groups)
}
sortedAt := strings.Index(groupQuerySQL, "), sorted AS (")
nameLookupAt := strings.Index(groupQuerySQL, `SELECT u.name FROM "user" u`)
if sortedAt < 0 || nameLookupAt < sortedAt {
t.Fatalf("assignee names must resolve after actor aggregation:\n%s", groupQuerySQL)
}
}
func TestIssueTableCompoundParentGroupsReturnExactStatusCells(t *testing.T) {
ctx := context.Background()
suffix := time.Now().UnixNano()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, $2)
RETURNING id
`, testWorkspaceID, fmt.Sprintf("Compound parent grouping %d", suffix)).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 4
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
var parentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
VALUES ($1, 'Parent context', 'done', 'none', 'member', $2, 1, $3, $4)
RETURNING id
`, testWorkspaceID, testUserID, finalNumber-3, projectID).Scan(&parentID); err != nil {
t.Fatalf("create parent: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
parent_issue_id, position, number, project_id
)
VALUES
($1, 'Child todo', 'todo', 'none', 'member', $2, $3, 2, $4, $5),
($1, 'Child review', 'in_review', 'none', 'member', $2, $3, 3, $4 + 1, $5),
($1, 'No parent', 'todo', 'none', 'member', $2, NULL, 4, $4 + 2, $5)
`, testWorkspaceID, testUserID, parentID, finalNumber-2, projectID); err != nil {
t.Fatalf("seed grouped issues: %v", err)
}
recorder := httptest.NewRecorder()
testHandler.ListIssueTableGroups(recorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
},
Group: issueTableGroupSpec{Kind: "compound", Primary: "parent", Secondary: "status"},
Page: issueTablePageRequest{Limit: 10},
}))
if recorder.Code != http.StatusOK {
t.Fatalf("compound groups status = %d: %s", recorder.Code, recorder.Body.String())
}
var response issueTableGroupsResponse
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode compound groups: %v", err)
}
if response.Total != 4 || len(response.Groups) != 2 {
t.Fatalf("unexpected compound group totals: %#v", response)
}
byKey := make(map[string]issueTableGroupDescriptorResponse, len(response.Groups))
for _, group := range response.Groups {
byKey[group.Key] = group
}
parent := byKey["parent:"+parentID]
if parent.Count != 2 || parent.Value.Parent == nil ||
parent.Value.Parent.Title != "Parent context" ||
parent.Value.ValueState != "value" {
t.Fatalf("parent descriptor lost context: %#v", parent)
}
parentCells := make(map[string]int64, len(parent.SecondaryGroups))
for _, cell := range parent.SecondaryGroups {
parentCells[cell.Value.Status] = cell.Count
if !strings.HasPrefix(cell.Key, "compound:") {
t.Fatalf("cell key is not opaque compound key: %q", cell.Key)
}
}
if parentCells["todo"] != 1 || parentCells["in_review"] != 1 {
t.Fatalf("unexpected parent status cells: %#v", parentCells)
}
noParent := byKey["parent:none"]
if noParent.Count != 2 || noParent.Value.ValueState != "unset" {
t.Fatalf("unexpected no-parent lane: %#v", noParent)
}
// A second parent has only a hidden-status child. Under a visible-status
// compound query it must stay a No-parent card instead of creating an
// empty lane. The first parent does have a visible child, so it is promoted
// to a lane header and must disappear from the No-parent rows and counts.
var hiddenParentNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = issue_counter + 2
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&hiddenParentNumber); err != nil {
t.Fatalf("reserve hidden-parent issue numbers: %v", err)
}
var hiddenParentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
VALUES ($1, 'Visible parent card', 'todo', 'none', 'member', $2, 5, $3, $4)
RETURNING id
`, testWorkspaceID, testUserID, hiddenParentNumber-1, projectID).Scan(&hiddenParentID); err != nil {
t.Fatalf("create hidden-only parent: %v", err)
}
if _, err := testPool.Exec(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
parent_issue_id, position, number, project_id
)
VALUES ($1, 'Hidden child', 'done', 'none', 'member', $2, $3, 6, $4, $5)
`, testWorkspaceID, testUserID, hiddenParentID, hiddenParentNumber, projectID); err != nil {
t.Fatalf("create hidden child: %v", err)
}
filteredGroup := issueTableGroupSpec{
Kind: "compound",
Primary: "parent",
Secondary: "status",
SecondaryValues: []string{"todo"},
}
filteredRecorder := httptest.NewRecorder()
testHandler.ListIssueTableGroups(filteredRecorder, newRequest("POST", "/api/issues/table/groups", issueTableGroupsRequest{
Query: issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
},
Group: filteredGroup,
Page: issueTablePageRequest{Limit: 10},
}))
if filteredRecorder.Code != http.StatusOK {
t.Fatalf("filtered compound groups status = %d: %s", filteredRecorder.Code, filteredRecorder.Body.String())
}
var filtered issueTableGroupsResponse
if err := json.NewDecoder(filteredRecorder.Body).Decode(&filtered); err != nil {
t.Fatalf("decode filtered compound groups: %v", err)
}
if filtered.Total != 3 || len(filtered.Groups) != 2 {
t.Fatalf("unexpected visible compound groups: %#v", filtered)
}
filteredByKey := make(map[string]issueTableGroupDescriptorResponse, len(filtered.Groups))
for _, descriptor := range filtered.Groups {
filteredByKey[descriptor.Key] = descriptor
}
if _, exists := filteredByKey["parent:"+hiddenParentID]; exists {
t.Fatalf("hidden-only parent lane was returned: %#v", filtered.Groups)
}
filteredParent := filteredByKey["parent:"+parentID]
filteredNoParent := filteredByKey["parent:none"]
if filteredParent.Count != 2 || filteredNoParent.Count != 2 {
t.Fatalf("parent header was not removed from aligned counts: parent=%#v no-parent=%#v", filteredParent, filteredNoParent)
}
cellKey := func(descriptor issueTableGroupDescriptorResponse, status string) string {
t.Helper()
for _, cell := range descriptor.SecondaryGroups {
if cell.Value.Status == status {
return cell.Key
}
}
t.Fatalf("missing %s cell in %#v", status, descriptor)
return ""
}
listCell := func(key string) issueTableRowsResponse {
t.Helper()
rowsRecorder := httptest.NewRecorder()
testHandler.ListIssueTableRows(rowsRecorder, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
},
Group: filteredGroup,
GroupKey: &key,
Hierarchy: issueTableHierarchyRequest{Enabled: false},
Page: issueTablePageRequest{Limit: 10},
}))
if rowsRecorder.Code != http.StatusOK {
t.Fatalf("filtered compound rows status = %d: %s", rowsRecorder.Code, rowsRecorder.Body.String())
}
var result issueTableRowsResponse
if err := json.NewDecoder(rowsRecorder.Body).Decode(&result); err != nil {
t.Fatalf("decode filtered compound rows: %v", err)
}
return result
}
noParentTodo := listCell(cellKey(filteredNoParent, "todo"))
if len(noParentTodo.Rows) != 2 {
t.Fatalf("unexpected visible no-parent rows: %#v", noParentTodo.Rows)
}
noParentIDs := map[string]bool{}
for _, row := range noParentTodo.Rows {
noParentIDs[row.Issue.ID] = true
}
if noParentIDs[parentID] || !noParentIDs[hiddenParentID] {
t.Fatalf("parent header/card semantics diverged: %#v", noParentIDs)
}
noParentDone := listCell(cellKey(filteredNoParent, "done"))
if len(noParentDone.Rows) != 0 {
t.Fatalf("promoted parent remained in No-parent rows: %#v", noParentDone.Rows)
}
}
func TestIssueTableHierarchyDoesNotCrossGroups(t *testing.T) {
ctx := context.Background()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, 'Server table cross-group hierarchy')
RETURNING id
`, testWorkspaceID).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 2
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
var parentID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
VALUES ($1, 'Todo parent', 'todo', 'none', 'member', $2, 1, $3, $4)
RETURNING id
`, testWorkspaceID, testUserID, finalNumber-1, projectID).Scan(&parentID); err != nil {
t.Fatalf("create parent: %v", err)
}
var childID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
parent_issue_id, position, number, project_id
)
VALUES ($1, 'Done child', 'done', 'none', 'member', $2, $3, 2, $4, $5)
RETURNING id
`, testWorkspaceID, testUserID, parentID, finalNumber, projectID).Scan(&childID); err != nil {
t.Fatalf("create child: %v", err)
}
query := issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Filters: issueTableFiltersRequest{},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
rowQuerySQL := ""
rowsHandler := *testHandler
rowsHandler.TxStarter = issueTableEnrichmentFailTxStarter{
inner: testHandler.TxStarter,
rowQuerySQL: &rowQuerySQL,
}
listGroup := func(groupKey string) issueTableRowsResponse {
t.Helper()
w := httptest.NewRecorder()
rowsHandler.ListIssueTableRows(w, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "status"},
GroupKey: &groupKey,
Hierarchy: issueTableHierarchyRequest{Enabled: true},
Page: issueTablePageRequest{Limit: 50},
}))
if w.Code != http.StatusOK {
t.Fatalf("list %s: status=%d body=%s", groupKey, w.Code, w.Body.String())
}
var response issueTableRowsResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode %s: %v", groupKey, err)
}
return response
}
doneRows := listGroup("status:done")
if len(doneRows.Rows) != 1 || doneRows.Rows[0].Issue.ID != childID {
t.Fatalf("done child must become a root in its own group: %#v", doneRows.Rows)
}
todoRows := listGroup("status:todo")
if len(todoRows.Rows) != 1 || todoRows.Rows[0].Issue.ID != parentID {
t.Fatalf("todo group root mismatch: %#v", todoRows.Rows)
}
if todoRows.Rows[0].DirectChildCount != 0 {
t.Fatalf("cross-group child leaked into todo parent count: %d", todoRows.Rows[0].DirectChildCount)
}
if !strings.Contains(rowQuerySQL, "membership AS NOT MATERIALIZED") ||
!strings.Contains(rowQuerySQL, "page AS MATERIALIZED") ||
!strings.Contains(rowQuerySQL, "(SELECT parent.id FROM membership parent") ||
strings.Contains(rowQuerySQL, "NOT EXISTS (SELECT 1 FROM membership parent") ||
strings.Contains(rowQuerySQL, "child_counts AS") {
t.Fatalf("hierarchy rows query must preserve ordered paging and page-local counts:\n%s", rowQuerySQL)
}
}
// TestIssueTableSelectPropertySortKeysetPagination walks the keyset cursor
// over a select-property sort. The sort expression ranks options by option
// order and carries the "::numeric" token that issueTableOrderBy sniffs into
// the cursor's cast type, so this pins three things at once: ordinal order,
// the numeric cursor round-trip (equal ranks tie, valueless rows trail), and
// no duplicated or skipped rows across pages. Explicit option ids in reverse
// lexical order make the ordinal assertions deterministic — a regression to
// raw-value ordering fails every run, not most runs.
func TestIssueTableSelectPropertySortKeysetPagination(t *testing.T) {
const (
lowID = "eeeeeeee-aaaa-4aaa-8aaa-aaaaaaaaaaaa" // lexically last
mediumID = "99999999-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
highID = "11111111-aaaa-4aaa-8aaa-aaaaaaaaaaaa" // lexically first
)
sel := createTestProperty(t, map[string]any{
"name": "TK" + uuid.NewString()[:8], "type": "select",
"config": map[string]any{"options": []map[string]any{
{"id": lowID, "name": "Low", "color": "#6b7280"},
{"id": mediumID, "name": "Medium", "color": "#f59e0b"},
{"id": highID, "name": "High", "color": "#ef4444"},
}},
})
projectID := dbfx.Project(t, "Select property sort pagination")
seedIssue := func(title string) string {
t.Helper()
return dbfx.Issue(t, title, testutil.Cols{"project_id": projectID})
}
lowA := seedIssue("keyset low a")
lowB := seedIssue("keyset low b")
medium := seedIssue("keyset medium")
high := seedIssue("keyset high")
unset := seedIssue("keyset unset")
for issueID, optionID := range map[string]string{lowA: lowID, lowB: lowID, medium: mediumID, high: highID} {
if w := setIssuePropertyRaw(t, issueID, sel.ID, optionID); w.Code != http.StatusOK {
t.Fatalf("seed select value: %d %s", w.Code, w.Body.String())
}
}
query := issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "property:" + sel.ID, Direction: "asc"},
}
fetchPage := func(cursor *string) issueTableRowsResponse {
t.Helper()
var response issueTableRowsResponse
testutil.Call(t, testHandler.ListIssueTableRows, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "none"},
Page: issueTablePageRequest{Limit: 2, Cursor: cursor},
})).Want(http.StatusOK).JSON(&response)
return response
}
var order []string
seen := make(map[string]struct{}, 5)
cursor := (*string)(nil)
for page := 0; ; page++ {
if page > 5 {
t.Fatalf("cursor did not terminate after %d pages", page)
}
response := fetchPage(cursor)
for _, row := range response.Rows {
if _, duplicate := seen[row.Issue.ID]; duplicate {
t.Fatalf("issue %s repeated across pages", row.Issue.ID)
}
seen[row.Issue.ID] = struct{}{}
order = append(order, row.Issue.ID)
}
if response.NextCursor == nil {
break
}
cursor = response.NextCursor
}
if len(order) != 5 {
t.Fatalf("paginated rows = %d, want all 5 seeded issues", len(order))
}
index := make(map[string]int, len(order))
for i, id := range order {
index[id] = i
}
if !(index[lowA] < index[medium] && index[lowB] < index[medium]) {
t.Fatalf("low-ranked issues not first: %v", order)
}
if !(index[medium] < index[high] && index[high] < index[unset]) {
t.Fatalf("option order violated across pages: medium=%d high=%d unset=%d", index[medium], index[high], index[unset])
}
}
func TestIssueTableHierarchyRootKeysetPagination(t *testing.T) {
ctx := context.Background()
var projectID string
if err := testPool.QueryRow(ctx, `
INSERT INTO project (workspace_id, title)
VALUES ($1, 'Server table hierarchy pagination')
RETURNING id
`, testWorkspaceID).Scan(&projectID); err != nil {
t.Fatalf("create project: %v", err)
}
t.Cleanup(func() {
_, _ = testPool.Exec(context.Background(), `DELETE FROM issue WHERE project_id = $1`, projectID)
_, _ = testPool.Exec(context.Background(), `DELETE FROM project WHERE id = $1`, projectID)
})
var finalNumber int
if err := testPool.QueryRow(ctx, `
UPDATE workspace
SET issue_counter = GREATEST(
issue_counter,
(SELECT COALESCE(MAX(number), 0) FROM issue WHERE workspace_id = $1)
) + 3
WHERE id = $1
RETURNING issue_counter
`, testWorkspaceID).Scan(&finalNumber); err != nil {
t.Fatalf("reserve issue numbers: %v", err)
}
rows, err := testPool.Query(ctx, `
INSERT INTO issue (
workspace_id, title, status, priority, creator_type, creator_id,
position, number, project_id
)
SELECT $1, 'Hierarchy root ' || n::text, 'todo', 'none', 'member', $2,
n::double precision, $3 + n - 1, $4
FROM generate_series(1, 3) AS n
ORDER BY n
RETURNING id
`, testWorkspaceID, testUserID, finalNumber-2, projectID)
if err != nil {
t.Fatalf("seed hierarchy roots: %v", err)
}
expectedIDs := make(map[string]struct{}, 3)
for rows.Next() {
var issueID string
if err := rows.Scan(&issueID); err != nil {
rows.Close()
t.Fatalf("scan hierarchy root: %v", err)
}
expectedIDs[issueID] = struct{}{}
}
if err := rows.Err(); err != nil {
rows.Close()
t.Fatalf("seed hierarchy roots: %v", err)
}
rows.Close()
query := issueTableQuerySpec{
Scope: issueTableScope{Kind: "project", ProjectID: projectID},
Sort: issueTableSortRequest{Field: "position", Direction: "asc"},
}
fetchPage := func(cursor *string) issueTableRowsResponse {
t.Helper()
w := httptest.NewRecorder()
testHandler.ListIssueTableRows(w, newRequest("POST", "/api/issues/table/rows", issueTableRowsRequest{
Query: query,
Group: issueTableGroupSpec{Kind: "none"},
Hierarchy: issueTableHierarchyRequest{Enabled: true},
Page: issueTablePageRequest{Limit: 2, Cursor: cursor},
}))
if w.Code != http.StatusOK {
t.Fatalf("list hierarchy roots: status=%d body=%s", w.Code, w.Body.String())
}
var response issueTableRowsResponse
if err := json.NewDecoder(w.Body).Decode(&response); err != nil {
t.Fatalf("decode hierarchy roots: %v", err)
}
return response
}
first := fetchPage(nil)
if first.Total != 3 || len(first.Rows) != 2 || first.NextCursor == nil {
t.Fatalf("unexpected first hierarchy page: total=%d rows=%d cursor=%v", first.Total, len(first.Rows), first.NextCursor)
}
second := fetchPage(first.NextCursor)
if second.Total != 0 || len(second.Rows) != 1 || second.NextCursor != nil {
t.Fatalf("unexpected second hierarchy page: total=%d rows=%d cursor=%v", second.Total, len(second.Rows), second.NextCursor)
}
seenIDs := make(map[string]struct{}, 3)
for _, page := range []issueTableRowsResponse{first, second} {
for _, row := range page.Rows {
if _, duplicate := seenIDs[row.Issue.ID]; duplicate {
t.Fatalf("hierarchy root %s repeated across pages", row.Issue.ID)
}
seenIDs[row.Issue.ID] = struct{}{}
}
}
if len(seenIDs) != len(expectedIDs) {
t.Fatalf("reachable hierarchy roots = %d, want %d", len(seenIDs), len(expectedIDs))
}
for issueID := range expectedIDs {
if _, ok := seenIDs[issueID]; !ok {
t.Fatalf("hierarchy root %s was not reachable through pagination", issueID)
}
}
}