mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-26 20:45:37 +02:00
Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
1431 lines
51 KiB
Go
1431 lines
51 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
)
|
|
|
|
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"},
|
|
},
|
|
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"},
|
|
},
|
|
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 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 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",
|
|
)
|
|
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)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|