Files
multica/server/internal/handler/comment_list_test.go
Bohan Jiang 6f5fbb7813 feat(comments): thread-aware list with composite cursor (MUL-2340) (#2787)
* feat(comments): thread-aware list with composite cursor (MUL-2340)

Adds three optional query params to GET /api/issues/{id}/comments and the
matching `multica issue comment list` flags:

- `thread=<comment-uuid>` resolves the anchor to the thread root via a
  recursive CTE (defends against any future nested replies) and returns
  root + all descendants chronologically. Anchor can be any comment in
  the thread, root or reply.
- `recent=<N>` returns the newest N comments for the issue, ordered
  chronologically in the response.
- `before=<RFC3339>` + `before-id=<uuid>` form a composite cursor for
  stable pagination of `recent`. Both must be set together; a
  timestamp-only cursor is rejected because ties on `created_at` would
  let the existing `(created_at ASC, id ASC)` total order skip or
  duplicate rows across pages.

Flag combination rules: `thread` is exclusive with `recent` and the
cursor; both may combine with `since`. Server and CLI enforce the same
matrix; the CLI fails fast locally so callers don't pay for a 400
round-trip.

Default behaviour (no params) is unchanged — full chronological dump
capped at commentHardCap — so the desktop UI and existing `--since`
polling are untouched. Agent prompt updates land in a follow-up PR so
the new CLI capabilities ship and bake first.

Co-authored-by: multica-agent <github@multica.ai>

* fix(comments): reject cursor without recent and align CLI/server on invalid --recent (MUL-2340)

Elon's PR #2787 second review flagged two gaps in the flag combination
matrix:

- server: GET /comments?before=...&before_id=... without `recent` was
  silently dropped by fetchCommentsForList (RecentN=0 fell through to
  the default / since path), so callers got the full timeline instead
  of the documented "before X" semantics. Now returns 400.
- CLI: --recent 0 / --recent -3 were collapsed with "flag not passed"
  by `recent > 0`, so an explicit invalid value silently fell back to
  the default list. Switched to Flags().Changed("recent") so explicit
  non-positive values fail loudly. Also enforces that --before /
  --before-id only appear with explicit --recent (mirrors the new
  server-side rule).

Tests:
- server flag matrix gains `before + before_id without recent → 400`.
- CLI gains TestRunIssueCommentListFlagGuards covering `--recent 0`,
  `--recent -3`, cursor-without-recent, and the thread/recent
  exclusivity path under the new Changed()-based check. The mock
  server fatals if a request reaches /comments, proving the guards
  fire before any HTTP round-trip.

Co-authored-by: multica-agent <github@multica.ai>

* feat(comments): make `recent` thread-grouped with a thread cursor (MUL-2340)

Bohan pushed back on the row-based `recent=N` shape: comments form a tree,
not a list, and the newest N rows can come from N unrelated threads, giving
the agent N disjoint conversational tails. Replace the row-based query with
a thread-grouped one before #2787 merges so we never ship the wrong shape:

- `recent=N` now returns the N most recently active threads (root + every
  descendant per thread). A thread's recency is MAX(created_at) across its
  whole subtree, so a stale-but-recently-replied thread outranks an old
  quiet one — exactly the property row-recent loses.
- The cursor is now a *thread* cursor: `before` = a thread's
  last_activity_at, `before_id` = its root comment id. The pair walks
  threads strictly less recent than the page's oldest-active thread. The
  cursor surfaces via `X-Multica-Next-Before` / `X-Multica-Next-Before-Id`
  response headers (empty when there are no older threads); the CLI
  forwards the same pair to stderr after listing.
- Row-based `recent` is gone — there is no internal caller and the prompt
  update has not shipped yet, so there is no compat surface to preserve.
- Response body shape unchanged (flat JSON array, chronological). Default
  and `--since` paths untouched. Desktop UI keeps working.

Tests:
- recent=1 returns the freshest-active thread fully; recent=2 returns both
  with the older-active thread first (oldest-active → freshest tail).
- Stale-but-fresh: a thread whose root is older but has a fresh reply
  outranks a thread whose root is newer but quiet.
- Cursor headers emitted only on full pages; empty on the final page.
- Pagination walks threads root2 → root1 → empty, no skips/duplicates.
- Tie-break: three threads sharing last_activity_at paginate one-at-a-time
  using (last_activity_at, root_id) ordering — verifies the timestamp-only
  cursor failure mode is fixed for the thread case too.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 19:28:26 +08:00

579 lines
20 KiB
Go

package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"time"
"github.com/google/uuid"
)
// cursorQuery builds a properly URL-encoded query string for the recent +
// thread-cursor path. RFC3339Nano timestamps contain `:` and may contain `+`,
// both of which need escaping so they survive `(*url.URL).Query()` parsing on
// the server side.
//
// `before` and `beforeID` here name a *thread* (last_activity_at, root_id),
// not a single row — the recent path is thread-grouped (#2340).
func cursorQuery(recent int, before, beforeID string) string {
v := url.Values{}
if recent > 0 {
v.Set("recent", strconv.Itoa(recent))
}
if before != "" {
v.Set("before", before)
}
if beforeID != "" {
v.Set("before_id", beforeID)
}
return v.Encode()
}
// nextThreadCursor reads the (before, before-id) headers the recent path
// emits when there is likely an older page to scroll to. Empty pair means
// the server signalled "no more threads".
func nextThreadCursor(w *httptest.ResponseRecorder) (string, string) {
return w.Header().Get("X-Multica-Next-Before"), w.Header().Get("X-Multica-Next-Before-Id")
}
// commentListFixture seeds an issue with a known comment graph for the
// thread / recent / cursor tests. The shape:
//
// root1 (oldest)
// ├── r1a
// └── r1b
// └── r1b1 (nested reply — defends Elon's point 2: recursive root walk)
// root2 (newer, separate thread)
// ├── r2a
// └── r2b (newest overall)
//
// Each comment is inserted with an explicit created_at so ordering and
// cursor behavior are deterministic.
type commentListFixture struct {
IssueID string
Root1 string
R1a string
R1b string
R1b1 string
Root2 string
R2a string
R2b string
Base time.Time
}
func newCommentListFixture(t *testing.T) commentListFixture {
t.Helper()
ctx := context.Background()
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
VALUES ($1, 'member', $2, $3)
RETURNING id
`, testWorkspaceID, testUserID, "comment list fixture").Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
base := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second)
insert := func(parent *string, offset time.Duration, body string) string {
t.Helper()
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', $5, $6)
RETURNING id
`, issueID, testWorkspaceID, testUserID, body, parent, base.Add(offset)).Scan(&id); err != nil {
t.Fatalf("insert comment %q: %v", body, err)
}
return id
}
root1 := insert(nil, 0, "root1")
r1a := insert(&root1, 1*time.Minute, "r1a")
r1b := insert(&root1, 2*time.Minute, "r1b")
r1b1 := insert(&r1b, 3*time.Minute, "r1b1") // nested reply: parent is a reply, not a root
root2 := insert(nil, 10*time.Minute, "root2")
r2a := insert(&root2, 11*time.Minute, "r2a")
r2b := insert(&root2, 12*time.Minute, "r2b")
return commentListFixture{
IssueID: issueID,
Root1: root1, R1a: r1a, R1b: r1b, R1b1: r1b1,
Root2: root2, R2a: r2a, R2b: r2b,
Base: base,
}
}
func decodeComments(t *testing.T, body []byte) []CommentResponse {
t.Helper()
var resp []CommentResponse
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("decode comments: %v", err)
}
return resp
}
func listComments(t *testing.T, issueID, query string) (*httptest.ResponseRecorder, []CommentResponse) {
t.Helper()
w := httptest.NewRecorder()
url := "/api/issues/" + issueID + "/comments"
if query != "" {
url += "?" + query
}
r := newRequest("GET", url, nil)
r = withURLParam(r, "id", issueID)
testHandler.ListComments(w, r)
if w.Code != http.StatusOK {
return w, nil
}
return w, decodeComments(t, w.Body.Bytes())
}
func ids(rows []CommentResponse) []string {
out := make([]string, len(rows))
for i, c := range rows {
out[i] = c.ID
}
return out
}
func eqIDs(t *testing.T, got, want []string, ctx string) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s: ids len got=%d want=%d\ngot=%v\nwant=%v", ctx, len(got), len(want), got, want)
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%s: ids[%d] got=%s want=%s\ngot=%v\nwant=%v", ctx, i, got[i], want[i], got, want)
}
}
}
// TestListComments_DefaultPreservesChronologicalOrder is a guard against
// silent regressions in the unparameterized list path — agents and the UI
// both depend on chronological order.
func TestListComments_DefaultPreservesChronologicalOrder(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
_, rows := listComments(t, fx.IssueID, "")
want := []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1, fx.Root2, fx.R2a, fx.R2b}
eqIDs(t, ids(rows), want, "default order")
}
// TestListComments_ThreadResolvesFromAnyAnchor proves Elon's point 2:
// regardless of whether the anchor is a root, a direct reply, or a nested
// reply (parent_id points at another reply), the server walks up to the
// thread root and returns root + every descendant.
func TestListComments_ThreadResolvesFromAnyAnchor(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
wantThread1 := []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1}
t.Run("anchor is root", func(t *testing.T) {
_, rows := listComments(t, fx.IssueID, "thread="+fx.Root1)
eqIDs(t, ids(rows), wantThread1, "anchor=root1")
})
t.Run("anchor is direct reply", func(t *testing.T) {
_, rows := listComments(t, fx.IssueID, "thread="+fx.R1a)
eqIDs(t, ids(rows), wantThread1, "anchor=r1a (direct reply)")
})
t.Run("anchor is nested reply", func(t *testing.T) {
// r1b1.parent_id = r1b, which itself is a reply. The recursive CTE
// must climb root1 → r1b → r1b1 to resolve the root.
_, rows := listComments(t, fx.IssueID, "thread="+fx.R1b1)
eqIDs(t, ids(rows), wantThread1, "anchor=r1b1 (nested reply)")
})
t.Run("anchor in other thread returns only that thread", func(t *testing.T) {
_, rows := listComments(t, fx.IssueID, "thread="+fx.R2a)
eqIDs(t, ids(rows), []string{fx.Root2, fx.R2a, fx.R2b}, "anchor=r2a")
})
}
// TestListComments_ThreadAnchorErrors covers the user-facing error surface
// for the thread path. The unknown-anchor case is what catches the typical
// "agent pasted a stale UUID" footgun — the server returns 404 instead of
// silently returning an empty list (which would otherwise be
// indistinguishable from a deleted thread).
func TestListComments_ThreadAnchorErrors(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
t.Run("non-uuid thread returns 400", func(t *testing.T) {
w, _ := listComments(t, fx.IssueID, "thread=not-a-uuid")
if w.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d: %s", w.Code, w.Body.String())
}
})
t.Run("unknown thread anchor returns 404", func(t *testing.T) {
w, _ := listComments(t, fx.IssueID, "thread=00000000-0000-0000-0000-000000000001")
if w.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d: %s", w.Code, w.Body.String())
}
})
}
// TestListComments_RecentReturnsMostRecentlyActiveThreads pins the
// thread-grouped semantics from #2340. Row-based "newest N comments" would
// have surfaced [root2, r2a, r2b] for N=3 — a single thread's tail. The
// thread-grouped path treats the unit as a thread (root + descendants) and
// ranks threads by MAX(created_at) over the subtree, so:
//
// - recent=1 → the single freshest-active thread (root2 thread) fully
// expanded, oldest-active thread suppressed.
// - recent=2 → both threads, with the older-active thread first so the
// freshest sits at the prompt tail (closest to "now").
func TestListComments_RecentReturnsMostRecentlyActiveThreads(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
t.Run("recent=1 returns the freshest-active thread fully", func(t *testing.T) {
_, rows := listComments(t, fx.IssueID, "recent=1")
eqIDs(t, ids(rows), []string{fx.Root2, fx.R2a, fx.R2b}, "recent=1")
})
t.Run("recent=2 returns both threads, older-active first", func(t *testing.T) {
_, rows := listComments(t, fx.IssueID, "recent=2")
// Threads sorted by (last_activity_at ASC, root_id ASC):
// root1 thread (last_activity = base + 3m via r1b1) FIRST
// root2 thread (last_activity = base + 12m via r2b) SECOND
// In-thread ordering is chronological.
want := []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1, fx.Root2, fx.R2a, fx.R2b}
eqIDs(t, ids(rows), want, "recent=2")
})
}
// TestListComments_RecentRanksStaleThreadAheadIfRecentlyReplied makes the
// MAX(created_at) ranking explicit: a thread whose root is old but which has
// a fresh reply must outrank a thread whose root is newer but quiet. Without
// this, "recent" decays into "most recent root" and misses the very signal
// that thread-grouping was meant to surface.
func TestListComments_RecentRanksStaleThreadAheadIfRecentlyReplied(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
VALUES ($1, 'member', $2, $3) RETURNING id
`, testWorkspaceID, testUserID, "stale-but-fresh fixture").Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
base := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second)
insert := func(parent *string, offset time.Duration, body string) string {
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, parent_id, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', $5, $6) RETURNING id
`, issueID, testWorkspaceID, testUserID, body, parent, base.Add(offset)).Scan(&id); err != nil {
t.Fatalf("insert: %v", err)
}
return id
}
// Stale-then-fresh: oldRoot was created at t=0 but received a reply at
// t=30m. quietRoot was created at t=15m and never replied.
oldRoot := insert(nil, 0, "oldRoot")
quietRoot := insert(nil, 15*time.Minute, "quietRoot")
freshReply := insert(&oldRoot, 30*time.Minute, "freshReply")
_, rows := listComments(t, issueID, "recent=1")
// Expected: only the oldRoot thread (oldRoot + freshReply). The
// quietRoot thread is suppressed because its last_activity_at is older
// than oldRoot's, even though its root was created later.
eqIDs(t, ids(rows), []string{oldRoot, freshReply}, "recent=1 picks freshly replied stale thread")
_ = quietRoot
}
// TestListComments_RecentEmitsThreadCursorWhenPageFull pins the header
// contract: a full page (threads in response == --recent N) emits the
// next-page cursor; an underfilled page emits nothing. The cursor points at
// the OLDEST thread in the page — that is the upper bound for the next
// (older) page.
func TestListComments_RecentEmitsThreadCursorWhenPageFull(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
t.Run("underfilled page emits no cursor", func(t *testing.T) {
// recent=5 with only 2 threads available — server has nothing older
// to offer. No cursor headers means the client stops paginating.
w, _ := listComments(t, fx.IssueID, "recent=5")
nb, nbid := nextThreadCursor(w)
if nb != "" || nbid != "" {
t.Fatalf("expected no cursor, got before=%q before_id=%q", nb, nbid)
}
})
t.Run("full page emits cursor pointing at oldest thread in page", func(t *testing.T) {
// recent=1: page is full (1 thread returned, 1 requested). Cursor
// must point at the (last_activity_at, root_id) of the only thread
// in the page so the next request can fetch older threads.
w, _ := listComments(t, fx.IssueID, "recent=1")
nb, nbid := nextThreadCursor(w)
if nbid != fx.Root2 {
t.Fatalf("cursor before_id = %q, want %q (root2 — newest thread)", nbid, fx.Root2)
}
if nb == "" {
t.Fatalf("cursor before is empty; expected RFC3339Nano timestamp")
}
if _, err := time.Parse(time.RFC3339Nano, nb); err != nil {
t.Fatalf("cursor before = %q is not RFC3339Nano: %v", nb, err)
}
})
}
// TestListComments_RecentWithThreadCursorScrollsOlderThreads walks the issue
// thread-by-thread using the cursor the server emits. Pinning this avoids
// the row-based regression where a "newest N comments" cursor would interleave
// rows from multiple threads and skip thread membership across pages.
func TestListComments_RecentWithThreadCursorScrollsOlderThreads(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
// Page 1: newest thread = root2.
w1, page1 := listComments(t, fx.IssueID, "recent=1")
eqIDs(t, ids(page1), []string{fx.Root2, fx.R2a, fx.R2b}, "page1 = root2 thread")
nb, nbid := nextThreadCursor(w1)
if nb == "" || nbid != fx.Root2 {
t.Fatalf("page1 cursor = (%q, %q), want (non-empty, %q)", nb, nbid, fx.Root2)
}
// Page 2: cursor points at root2 → server returns the next older thread
// (root1). All of root1's descendants come along.
w2, page2 := listComments(t, fx.IssueID, cursorQuery(1, nb, nbid))
eqIDs(t, ids(page2), []string{fx.Root1, fx.R1a, fx.R1b, fx.R1b1}, "page2 = root1 thread")
// Page 3: cursor points at root1 → no older threads exist, page is
// empty AND no cursor is emitted.
nb2, nbid2 := nextThreadCursor(w2)
if nb2 == "" || nbid2 != fx.Root1 {
t.Fatalf("page2 cursor = (%q, %q), want (non-empty, %q)", nb2, nbid2, fx.Root1)
}
w3, page3 := listComments(t, fx.IssueID, cursorQuery(1, nb2, nbid2))
if len(page3) != 0 {
t.Fatalf("page3: expected empty (no older threads), got %d rows: %v", len(page3), ids(page3))
}
nb3, nbid3 := nextThreadCursor(w3)
if nb3 != "" || nbid3 != "" {
t.Fatalf("page3 cursor = (%q, %q), want both empty (end-of-list)", nb3, nbid3)
}
}
// TestListComments_ThreadCursorStableUnderSameLastActivity locks the
// tie-break invariant for the thread cursor. Three threads with identical
// last_activity_at must paginate one-at-a-time without skips or duplicates,
// because (last_activity_at, root_id) — not just last_activity_at — is the
// total order. A timestamp-only cursor would either drop one thread or
// surface the same thread twice when ties land in the same microsecond.
func TestListComments_ThreadCursorStableUnderSameLastActivity(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
ctx := context.Background()
var issueID string
if err := testPool.QueryRow(ctx, `
INSERT INTO issue (workspace_id, creator_type, creator_id, title)
VALUES ($1, 'member', $2, $3) RETURNING id
`, testWorkspaceID, testUserID, "thread tie-break fixture").Scan(&issueID); err != nil {
t.Fatalf("create issue: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM issue WHERE id = $1`, issueID)
})
ts := time.Now().UTC().Add(-30 * time.Minute).Truncate(time.Millisecond)
insertRoot := func(body string) string {
var id string
if err := testPool.QueryRow(ctx, `
INSERT INTO comment (issue_id, workspace_id, author_type, author_id, content, type, created_at)
VALUES ($1, $2, 'member', $3, $4, 'comment', $5) RETURNING id
`, issueID, testWorkspaceID, testUserID, body, ts).Scan(&id); err != nil {
t.Fatalf("insert: %v", err)
}
return id
}
a := insertRoot("a")
b := insertRoot("b")
c := insertRoot("c")
// All three threads have last_activity_at = ts (each root is also the
// thread's only comment). Order is (ts, root_id) — UUID lex tie-break.
// Build canonical order by sorting the root ids and reversing (the SQL
// orders DESC for selection).
sorted := []string{a, b, c}
for i := 0; i < len(sorted); i++ {
for j := i + 1; j < len(sorted); j++ {
if sorted[i] > sorted[j] {
sorted[i], sorted[j] = sorted[j], sorted[i]
}
}
}
// Newest-first selection walks UUIDs DESC, so the page-1 thread is the
// largest UUID; response is then ordered ASC (oldest-active first =
// smallest-UUID-among-current-page) — with recent=1 there's only one
// thread per page so the body shows that thread alone.
wantOrder := []string{sorted[2], sorted[1], sorted[0]}
var got []string
w, page := listComments(t, issueID, "recent=1")
if len(page) != 1 {
t.Fatalf("page1: expected 1 thread (1 row), got %d", len(page))
}
got = append(got, page[0].ID)
for i := 0; i < 2; i++ {
nb, nbid := nextThreadCursor(w)
if nb == "" || nbid == "" {
t.Fatalf("page %d: missing cursor headers", i+1)
}
w, page = listComments(t, issueID, cursorQuery(1, nb, nbid))
if len(page) != 1 {
t.Fatalf("page %d: expected 1 thread (1 row), got %d", i+2, len(page))
}
got = append(got, page[0].ID)
}
eqIDs(t, got, wantOrder, "paginated walk")
}
// TestListComments_FlagCombinationRules locks Elon's point 4. The matrix is
// tiny on purpose — the goal is to ensure conflicting flags are rejected
// loudly at the API surface so the CLI's local validation cannot drift.
func TestListComments_FlagCombinationRules(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
cases := []struct {
name string
query string
status int
}{
{
name: "thread + recent rejected",
query: "thread=" + fx.Root1 + "&recent=5",
status: http.StatusBadRequest,
},
{
name: "thread + before rejected",
query: (func() string {
v := url.Values{}
v.Set("thread", fx.Root1)
v.Set("before", time.Now().UTC().Format(time.RFC3339))
v.Set("before_id", uuid.NewString())
return v.Encode()
})(),
status: http.StatusBadRequest,
},
{
name: "before without before_id rejected",
query: (func() string {
v := url.Values{}
v.Set("recent", "5")
v.Set("before", time.Now().UTC().Format(time.RFC3339))
return v.Encode()
})(),
status: http.StatusBadRequest,
},
{
name: "before_id without before rejected",
query: (func() string {
v := url.Values{}
v.Set("recent", "5")
v.Set("before_id", uuid.NewString())
return v.Encode()
})(),
status: http.StatusBadRequest,
},
{
name: "before + before_id without recent rejected",
// Cursor without --recent used to fall through to the default /
// since path and silently return the full timeline (the gap Elon
// called out in the PR #2787 second review). The 400 here pins
// the documented "cursor scrolls within a recent window" rule.
query: (func() string {
v := url.Values{}
v.Set("before", time.Now().UTC().Format(time.RFC3339))
v.Set("before_id", uuid.NewString())
return v.Encode()
})(),
status: http.StatusBadRequest,
},
{
name: "zero recent rejected",
query: "recent=0",
status: http.StatusBadRequest,
},
{
name: "negative recent rejected",
query: "recent=-3",
status: http.StatusBadRequest,
},
{
name: "non-numeric recent rejected",
query: "recent=lots",
status: http.StatusBadRequest,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
w, _ := listComments(t, fx.IssueID, tc.query)
if w.Code != tc.status {
t.Fatalf("query=%q\n got=%d want=%d body=%s", tc.query, w.Code, tc.status, w.Body.String())
}
})
}
}
// TestListComments_ThreadWithSinceFiltersWithinThread proves the allowed
// combination from the rules: `thread + since` returns only comments in
// that thread newer than `since`. The since filter is applied in-memory
// after the thread CTE so the root membership semantics stay intact.
func TestListComments_ThreadWithSinceFiltersWithinThread(t *testing.T) {
if testHandler == nil || testPool == nil {
t.Skip("database not available")
}
fx := newCommentListFixture(t)
// since = base+1m30s → drop root1, r1a; keep r1b, r1b1.
v := url.Values{}
v.Set("thread", fx.Root1)
v.Set("since", fx.Base.Add(90*time.Second).UTC().Format(time.RFC3339Nano))
_, rows := listComments(t, fx.IssueID, v.Encode())
eqIDs(t, ids(rows), []string{fx.R1b, fx.R1b1}, "thread+since")
}