mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 01:16:45 +08:00
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.
1606 lines
57 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|