Files
multica/server/internal/handler/issue_table_query_test.go
T
Naiyuan QingandClaude Fable 5 984a2c2bff MUL-5954: feat: saved issue views V1 (MUL-4796) (#6516)
* feat(issues): server-backed saved issue views API (MUL-4796)

issue_view table (migrations 262-264: table + two CONCURRENTLY partial
indexes) stores named filter definitions: query jsonb is the shared
identity, display jsonb only seeds a user's first open. Scope model is
workspace / my (with scope_variant, forced private) / project (validated
scope_id, deleted with the project in the same app transaction).

issue_view_preference (migration 265) holds each user's view-bar layout
(hidden + order doc) per surface container, last-write-wins.

CRUD + GET/PUT preference endpoints are gated on the saved_issue_views_v1
feature flag (404 while off, key published to the frontend config).
Authorization: private views 404 for everyone but the owner; shared views
editable by owner or workspace admin; updates use expected_revision -> 409.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(core): saved issue views client core (MUL-4796)

- zod schemas + parseWithFallback for views and preferences (definition
  blobs stay loose records interpreted per definition_version), with
  malformed-response tests per the API-compat rules
- issue-views module: list/create/update/delete + preference queries with
  wsId-scoped keys; preference upsert is optimistic (toggle-grade rollback)
- active-view store (zustand client state, URL is the durable carrier) and
  useActiveIssueView with missing-view detection
- baseline.ts: enum-sanitized FilterSnapshot of a view's query for
  value-level locking, delta chips, and baseline-aware resets
- view-store: clearFilterDimension + resetFiltersTo actions; surface store
  seeding routes server blobs through mergeViewStatePersisted

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): three-bar priority icons with a distinct urgent badge

low/medium/high share one three-bar frame (fill level = severity, ghost
bars at 35%); urgent breaks out of the scale as a filled badge with an
exclamation cutout — it is an interrupt, not a fourth bar. All glyphs
share the same 2-14 optical frame.

Board card: PickerWrapper's bare block div gave its inline-flex trigger a
line box (line-height 24px > 20px button), floating icons above the text
midline — the wrappers are flex containers now, and the priority trigger
gets a constant size-5 box so the row's rhythm doesn't depend on the glyph.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(views): saved views UI — view bar, save/edit dialog, locked view mode (MUL-4796)

View bar replaces the built-in tab row on Issues and My Issues: built-in
tabs and saved views render as one flat, per-user ordered row that wraps
instead of overflowing, with a menu for new-view / manage. The manage
dialog does drag reorder (translate-only transform, vertical axis lock),
show/hide (anchor built-in unhidable), and edit/delete for owned views.

Open-view semantics: the view's query is the baseline — its values render
checked-and-disabled in the filter menu, never as chips; chips show only
the user's additions and every reset path (chip x, clear, menu reset,
filtered-empty CTA, board/swimlane hide-column) returns to the baseline,
not to empty. Display seeds once per user (view:<id> surface key) and is
free afterwards. Built-in tab click exits; deleted/revoked views fall back
with a toast; ?view= deep links sync on web via a platform hook.

Save dialog doubles as the edit dialog (PATCH with expected_revision ->
409 toast); manager edits seed from the view definition, in-view edits
from the live panel. Filter chips bar, extracted IssueFilterMenu (virtual
frozen anchor), and the reworked display panel round out the header.

Also: filter menu consistency fixes (closeOnClick, calendar apply,
TableColumnPicker checkbox items), SelectGroup padding, dropdown anchor
passthrough, four-locale i18n.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(views): neutralize workspace tab filter while a saved view is open

The Members/Agents tabs are a coarse assignee-type filter carried in the
surface scope, outside the filter-menu dimensions a view can express. It
kept applying underneath an open view, so the same shared view returned
different rows depending on which tab the user stood on when opening it —
with nothing on screen to explain the difference (tabs dimmed, no chip).

While a view is active the workspace scope now resolves to actorKind
"all": a view's results are defined by the view alone, matching what the
dimmed tabs already imply. Deliberately NOT captured into saved queries —
a view condition must be visible and lockable in the filter menu, and
assignee-type has no menu representation (add the menu dimension first if
that need ever materializes; the loose schema takes the field additively).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): workspace scope_variant captures and applies the assignee-type tab

A workspace view now saves which built-in tab it was created from
(members/agents, NULL = all) as scope_variant, alongside the existing
my-scope variants. The save dialog shows a scope selector so the
variant can be switched later; scope_type stays immutable. While a
view is open, its own variant drives the workspace scope's
assignee-type axis instead of whichever tab the user stood on.

- migration 266 widens the scope_variant CHECKs (members/agents on
  workspace, NULL = all; my keeps its four variants; project stays NULL)
- UpdateIssueView persists scope_variant; handler validates the variant
  against the view's scope_type on create and update
- issue-surface applies activeView.scope_variant to effectiveScope,
  replacing the blanket tab neutralization
- issues-header seeds the dialog scope from the live tab

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): project pages get a real assignee-type axis and clearer variant copy

The Members/Agents tabs on project pages were decorative: they wrote the
global tab store but the project query never read it. The project scope
now carries actorKind like the workspace scope (assignee_types composes
with project_id server-side), so the tabs filter for real, and project
views join the same optional scope_variant vocabulary — saved from the
tab, switchable in the dialog, applied while the view is open.

- migration 266 (unmerged) widened in place: project allows
  members/agents, NULL = all
- handler treats workspace and project variants identically; new
  TestProjectIssueViewVariant covers persist / normalize / reject
- project scope key gains a per-tab segment (members/agents) so each tab
  keeps its own display state; the unrestricted tab keeps the old key
- save dialog shows the variant selector for project views; the surface
  now provides the dialog's default variant directly (header-side
  injection removed)
- variant labels spell out the meaning (Assigned to members/agents, Any
  assignee, Any relation) and project hints are variant-aware, x4 locales

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-table): project table scope honors assignee_types

The table/rows channel dropped the Members/Agents narrowing on project
pages: the client's project scope never sent assignee_types and the
server's project branch never compiled it, so switching tabs only
changed the highlight while table rows stayed put (the board/list
channel already filtered). Both sides now share the workspace scope's
optional assignee-type narrowing, with a compile test covering the
predicate and the invalid-type rejection.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): single assignee-type mapping + per-page tab state

One core helper pair (assigneeTypesForActorKind / actorKindForViewVariant)
replaces the four inlined tab->assignee_types literal mappings across
query-plan, the table spec compiler, and the view-variant application.

The assignee-type tab store becomes page-keyed (issues / project:<id>,
persisted with a v0 migration carrying the old global tab to the Issues
page): switching tabs inside a project no longer drags the Issues page or
other projects along. The save-view dialog's default variant now comes
from the header's own page tab, restoring the /issues default that the
surface-side injection had dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issues): converge all issue surfaces onto the table query channel

The legacy list/grouped query layer duplicated the table channel's scope
semantics behind enable-conditions that were provably always false: list
and status-board ride server status branches, every non-status grouping
and swimlane ride server group branches, and the assignee-board /
per-status load-more / flat-list / flat-export / client-side my:any
3-request merge fallbacks could never execute. Delete the whole layer:

- queries: myIssueListOptions, myIssueAssigneeGroupsOptions,
  issueAssigneeGroupsOptions, issueFlatListOptions/ExportOptions,
  fetchAllFlatPages / fetchAllMyFlatIssues / fetchAllMyFirstPages /
  fetchAllMyAssigneeGroups, compareIssuesForSort (server owns ordering)
- mutations: useLoadMoreByStatus, useLoadMoreByAssigneeGroup
- board/swimlane: the never-taken client-paginated columns, property
  pool loader, hidden-column client counts, legacy footer rows
- surface data hook / controller: dead queries and passthrough fields
- gantt keeps its scheduled-only endpoint and now compiles the
  assignee-type tab into it, restoring the change alongside the cleanup

query-plan shrinks to the scope's non-Table residue: scopeKey, the
gantt filter, and create defaults. issueKeys and cache invalidation
prefixes are untouched; mobile's own data layer is unaffected.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): review fixes — active view's variant wins in save dialog, stable dialog scope, dead mocks

Post-review pass on the convergence refactor:

- The save dialog's default variant now prefers the OPEN view's
  scope_variant over the page tab: saving a copy while an agents-scoped
  view is open must describe the rows on screen, not the tab underneath.
- The dialog scope prop is memoized on primitive projections — a fresh
  object per header render re-armed the dialog's draft-reset effect, so
  any background refetch wiped a half-typed name.
- Tests: persist migration coverage for the page-keyed tab store; gantt
  assignee_types reaches the request and forks the cache key.
- Dead legacy load-more mocks removed from three view test files; stale
  hidden-columns doc comment; unknown hidden-column totals render no
  count instead of a blank; my-relation plan branch is return-exhaustive;
  leftover blank lines.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): keep the grabbing cursor for the whole row drag in manage views

The handle's cursor classes only apply while the pointer hovers the
handle — the vertical-locked drag loses it immediately, and buttons the
pointer crosses flip it to pointer. Mirror the sidebar-resize cursor
contract: an html[data-dnd-dragging] rule forces cursor: grabbing (and
no text selection) document-wide for the drag's duration, toggled by the
DndContext lifecycle with an unmount safety net.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issues): my views apply their relation at the query layer, not by switching the tab

Opening a my-view used to overwrite the user's relation tab with the
view's variant (VARIANT_TO_TAB), so when an open view vanished
(deleted / access revoked) the fallback landed on the view's tab
instead of where the user actually was. Views are now applied the same
way on all three surfaces: while a view is open, effectiveScope
substitutes the view's variant — assignee-type for workspace/project,
relation for my — and the user's own tab state is never touched.
myRelationForViewVariant joins its sibling mapping in scope.ts with
unit coverage.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): ease the save-view dialog's expand and validation transitions

The display-defaults section snapped open/closed and the name-required
hint popped in. The panel now runs the Base UI height transition
(--collapsible-panel-height + data-starting/ending-style — the
tw-animate collapsible keyframes only know Radix's variable, so the
accordion's animate-* classes cannot be reused here), and the hint eases
in with the house animate-in vocabulary. Scoped to this dialog: the
shared Collapsible primitive stays untouched because chat/transcript
panels grow while open and a pinned panel height would clip them.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issues): filter chips ease in

Chips popped into the bar and the save-view dialog's embedded list with
no transition. Each chip now enters with the house fade+zoom vocabulary,
keyed per dimension so value edits inside an existing chip don't
re-animate. No exit animation by design.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): drag-reorder the view bar, tab context menu, pin views to sidebar

The bar's tabs (built-ins and saved views alike) now reorder by drag,
writing the same per-user preference document the manage dialog edits —
one list, two editors. Saved-view tabs gain a context menu:

- Edit: visible to everyone, disabled without manage permission (the
  greyed row is the signal); Delete: rendered only for the owner or a
  workspace admin on shared views — mirroring the server's
  canManageIssueView — and routed through the shared confirm dialog now
  extracted from the manage dialog. The manage dialog's own affordances
  follow the same widened rule (admins included).
- Pin to sidebar: pinned_item grows a 'view' type (migration 267; create
  validates read access so foreign private views 404). Sidebar pin rows
  resolve the view's name via the new GET-by-id options, render the
  view-bar icon, activate the view in the store and navigate to its
  owning surface on click. Flag-off clients keep view pins dormant
  instead of auto-unpinning; deleted views follow the existing
  404-auto-unpin pattern.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): review fixes — guard preference wipes, never auto-unpin views

Two data-loss paths from review, plus hardening:

- A drag landing before the views list loaded pruned the preference
  document against an empty list, silently erasing every saved-view
  entry from order and hidden. savePrefs now refuses to write until the
  list has actually loaded (viewsReady threads down from
  useActiveIssueView).
- View pins are exempt from 404-auto-unpin: the detail endpoint also
  404s when the feature flag is switched off server-side, and a client
  that booted with the flag on cannot tell the difference — a flag flip
  would have deleted every view pin permanently. Deleted views' rows
  hide instead.
- POST /api/pins with item_type=view now sits behind the same feature
  gate as every other view endpoint.
- SortableBarTab: drop dnd-kit's aria attributes (they nested a second
  focusable role=button around the tab), move the wasDragged ref into an
  effect, and clear it after a cancelled drag so the next real click
  isn't swallowed.
- Sidebar view pins carry ?view= in their href so a web reload stays on
  the view; scope resolution collapsed to one branch driving both the
  path and the container key.
- Malformed GET /api/issue-views/{id} responses pinned by test as null
  (hide the row), never an error (which alone may unpin).

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): single-row view bar with overflow popover

The bar no longer wraps: tabs that don't fit collapse into a trailing
popover, Linear-style, with two differences — built-ins are first-class
orderable entries, and the whole list (hidden rows dimmed) lives in the
pulled-down panel with drag reorder, per-row actions (edit/delete under
the same permission rule, pin, hide/show) and a new-view entry.

- useSingleRowFit (views/common): hidden mirror row measures natural tab
  widths; ResizeObserver + change-guarded per-commit remeasure computes
  the fitting prefix with trigger reservation. First reusable
  collapse-into-menu primitive in the repo.
- The open view is always visible: when it overflows it takes the
  trigger slot, showing its name + chevron (wider reservation; the
  two-pass promotion settle is monotonic, no oscillation).
- Every tab now shares one max width (max-w-40) and truncates with the
  full name in tooltips/rows.
- ManageViewsDialog retired: the popover carries all of its duties
  (reorder, hide/show incl. built-ins, edit/delete); hiding stays
  impossible for the anchor built-in and still exits a hidden open view.
  DeleteViewConfirm and ViewBarItem move to view-bar-popover.tsx;
  orphaned i18n keys dropped, row action keys added x4 locales.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): restore [layers] menu + manage dialog; 'more' appears only on overflow; fix zero-width bar

The single-row rework overreached: it replaced the existing new/manage
entries and dialog with an all-in-one popover, and the bar's container
collapsed to zero width (content-sized parent + measurement feedback),
rendering an empty header. Back to the intended shape:

- The [layers] menu (new view / manage views) and the manage dialog are
  restored exactly as before; DeleteViewConfirm and ViewBarItem stay
  shared from view-bar-popover.
- The 'more' trigger exists ONLY while tabs overflow, showing just the
  overflowed entries (click to open; per-row edit/delete/pin/hide with
  the same permission rules — those tabs have no rendered surface to
  right-click). The open view still takes the trigger slot when it
  overflows.
- Header view-bar slots gain flex-1 so the fit measures a real width;
  reserve tiers (menu / +more / +promoted) settle monotonically.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): list panel shows ALL tabs with drag reorder; trigger always visible

The pulled-down panel now lists every bar entry (built-ins included) in
bar order with vertical drag reorder — since the bar renders the longest
fitting prefix, reordering in the panel directly decides what sits on
the bar versus tucks away, with a separator marking the current fold.
The trigger no longer appears/disappears with overflow: it is a fixed
part of the bar's right side (still replaced by the open view's own tab
when that view overflows). Row actions (edit/delete/pin/hide) and the
[layers] new/manage entries are unchanged.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): kill the post-drop bounce with a synchronous order mirror

The preference mutation is optimistic but its onMutate is async: on drop,
dnd-kit clears the drag transform in the same frame while the reordered
list lands a beat later, so the dropped tab flashed back to its old slot
and then jumped — the manage dialog never bounced because it mirrors the
new order synchronously in the drag handler. Both bar drag and panel
drag now route through applyMove: set a local order mirror in the same
tick, persist, and clear the mirror once the preference data catches up.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): panel row menu is always visible, not hover-revealed

Hover-reveal made the three-dot row menu unreachable on touch devices;
it is a primary affordance in the list panel, so it renders always.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): long view names truncate/wrap everywhere

The manage dialog's row strip is a grid item — min-width:auto let an
80-char name blow past the dialog frame, carrying the row controls with
it. Pin the wrapper (min-w-0 + overflow-hidden) and the rows (min-w-0)
so labels truncate as designed. The delete confirmation and the save
dialog's scope hint interpolate names/titles into prose — break-words
so an unbroken name wraps instead of overflowing. Audited every other
name surface (bar tabs, promoted trigger, panel rows, sidebar pins,
context menus): all already bounded.

Co-Authored-By: Claude Fable 5 <[email protected]>

* refactor(issue-views): remove the saved_issue_views_v1 feature flag

The feature ships unconditionally. The flag never reached any released
client, so — unlike the retired flags that live on as always-on compat
keys — the key is removed outright on both sides:

- server: the issueViewsEnabled 404 gate drops from every view endpoint,
  the preference endpoints and the view pin type; the key leaves the
  registry and the frontend-public list; gate tests and the per-test
  enable helper go with it
- frontend: the === true checks, the pre-view-bar fallback tab rows in
  both headers, and useActiveIssueView's enabled parameter are deleted;
  the view bar is now the only rendering
- compat holds by construction: a NEW client against an OLD backend gets
  404s on the list/preference queries → empty views, viewsReady stays
  false (no preference writes), sidebar view pins hide without
  auto-unpinning (comment updated to name this as the reason); an OLD
  client against the NEW backend reads an absent flag as false and keeps
  the feature hidden

Full-suite run also surfaced that the workspace deletion manifest never
classified issue_view / issue_view_preference: both now fall with the
other issue roots in DeleteWorkspaceIssueRoots (no-FK policy: explicit
teardown), and the manifest records the decision.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): fit reserve applies even when every tab fits

The all-fit fast path skipped the reserve — a leftover from when the
overflow trigger only rendered on overflow. With the list trigger and
the [layers] menu now permanent, a row whose tabs exactly filled the
container declared everything fitting and overflow-hidden clipped the
trailing chrome. The reserve is now subtracted unconditionally.

Co-Authored-By: Claude Fable 5 <[email protected]>

* feat(issue-views): a freshly created view opens itself

Creating a view now activates it on its surface immediately — the view
you just saved is the one you meant to be looking at. Skipped only when
the create response fails to parse (the list refetch still shows it).

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): creating a view no longer bounces the surface to the default tab

Activation raced the list invalidation: the fresh view's id was set
active before the refetched list contained it, the stale list read as
'view deleted', and the missing-view fallback kicked the surface back to
the anchor tab (with an 'unavailable' toast). Two-sided fix: the create
mutation seeds the created view into its scope's list cache before
invalidating, and the missing verdict now requires the list to not be
mid-refetch.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): release-gate fixes — rollback safety, limits, old-client pin compat, revoke sweep

Addresses the pre-ship review blockers:

- CI styles: text-muted-foreground/70 -> text-faint-foreground (solid
  tone rule) in the header hint and the dialog my-note; the chip emoji
  stack's text-[9px] -> text-micro (type-scale rule). apps/web
  text-contrast + type-scale suites pass.
- migration 269 down normalizes workspace/project members/agents
  variants to NULL before restoring the old constraints — verified on a
  scratch DB with real variant data (up -> write -> down).
- view pins are withheld from the legacy /api/pins contract unless the
  client opts in (?include=view): old Desktop builds classified any
  non-issue pin as a project pin and permanently auto-unpinned it on
  404. New clients opt in; regression test pins one and asserts the
  legacy list never leaks it.
- write hardening: MaxBytesReader (128KB) on view create/update and
  preference PUT; isJSONObject rejects JSON null (was a DB-CHECK 500);
  per-owner view quota (100/workspace); list query LIMIT 200. Boundary
  tests for null blob, oversized body, and quota.
- member revoke now sweeps the departed member's PRIVATE views and their
  view-bar preferences in the same transaction (shared views stay, same
  rule as private quick actions) — the migration comments promised this;
  test covers private-gone/shared-stays/prefs-gone.
- sidebar comment no longer claims project ?view= reload support.

Co-Authored-By: Claude Fable 5 <[email protected]>

* fix(issue-views): deleting a view sweeps its sidebar pins on every path

View pins never auto-unpin client-side (old-backend safety), so a pin
whose view is gone was invisible in the UI and unremovable forever —
and could leave an empty-looking Pinned group. All three deletion paths
now sweep matching pinned_item rows atomically in the same statement
via CTEs: direct view delete, project deletion (its scoped views), and
member revoke (their private views). Regression tests cover each path.

Co-Authored-By: Claude Fable 5 <[email protected]>

---------

Co-authored-by: Claude Fable 5 <[email protected]>
2026-08-10 11:55:05 +08:00

1473 lines
52 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 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 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)
}
}
}