Files
pad/internal/server/handlers_items_test.go
T
xarmian c72fe5a663 feat(items): add unparented filtering contract (#926)
* feat(items): add unparented filtering contract

* fix(items): preserve unparented projection state

* fix(views): preserve reserved filter on reset

* fix(items): resync projection scope changes

* fix(items): address PR 926 review findings

- localIndex: fetch snapshot before clearing store/cache in resyncProjectionScope (no data-loss window on fetch failure)
- items: degrade to committed item when post-parent-link readback fails instead of 500
- items: treat unparented=<non-true> as a field filter so a schema field named unparented still filters
- persistence: delete dead persistCursor
- mark validateUnparentedListRequest canonical; cross-reference the 3 early-feedback copies

* fix(items): resync race + purge safety per Codex review (round 1)

- resyncProjectionScope: merge-reconcile instead of blunt clear so a higher-seq
  upsert/delta racing the snapshot fetch is preserved (not erased) and the cursor
  never regresses below it
- recheck generation after persistWipe so a sign-out/403 purge during the wipe
  can't resurrect purged rows via persistDelta
- snapshot rows authoritatively replace local copies (drop is_unparented on
  downgrade); mergeRow's projection-preservation is bypassed for resync

* fix(items): sanitize projection bit on preserved racing rows per Codex review (round 2)

When a projection resync lands a restricted snapshot, strip is_unparented from
any racing higher-seq row kept by the seq guards — the old scope no longer grants
it. Keep the row itself (dropping it would reintroduce the racing-mutation data
loss; server 403 enforces real visibility).

* fix(items): transactional cache replace in resync per Codex review (round 3)

Replace wipe()+persistDelta() in resyncProjectionScope with a single
persistReplace() transaction (clear + write in one tx). Avoids the
deleteDatabase() onblocked cross-tab hang where a pending delete stalls the
following reopen+write indefinitely, wedging the resync promise. wipe() stays
for the sign-out / schema-mismatch full-teardown paths.

* fix(items): drop-and-replay resync reconciliation per Codex review (round 4)

Rework resyncProjectionScope: drop every row absent from the authoritative
snapshot (not just older-than-cursor ones) and pin the cursor to the snapshot
cursor. A post-snapshot mutation the client can still see is re-fetched by the
next /items-changes?since=cursor under the NEW scope, so visible rows return and
old-scope-hidden rows stay gone — no old-scope row survives the resync, and
nothing is permanently lost. Present-in-snapshot racing edits are still kept
(is_unparented stripped under a restricted scope).

* fix(items): continue delta poll after resync so replay actually fires (round 5)

The drop-and-replay resync (round 4) pins the cursor to the snapshot cursor so
post-snapshot mutations re-fetch under the new scope — but both poll loops broke
out / returned immediately after the resync, so the replay never ran until an
unrelated sync/reload. Both callers now continue the loop from the pinned cursor;
resync already aligned the scope so the branch can't re-fire, and the existing
50-iteration cap bounds it.

* fix(items): keep pendingResync set until replay catches up (round 6)

resyncProjectionScope cleared pendingResync after installing the snapshot but
before the pinned-cursor replay drained. If that replay later failed or hit the
50-page cap, pendingResync stayed false and the next bootstrap() no-opped with
racing mutations still missing. Let the reconcile loop's caughtUp logic own the
flag instead.

* fix(items): set pendingResync when any resync begins (round 7)

Round 6 removed the premature clear but only the bootstrap path pre-sets
pendingResync; a page deltaSync resync ran with it false, so a failed/capped
replay there wouldn't trigger a bootstrap resume. Set pendingResync=true at the
start of resyncProjectionScope so any caller marks catch-up pending; the
reconcile loop clears it on caughtUp.

* fix(items): fence stale optimistic writes + epoch-guard resync catch-up (round 8)

Adds a resync-epoch + fenced-id mechanism to close the last two race classes:

- fencedIds: a resync records the ids it dropped (hidden under the new scope).
  upsert() refuses a fenced id, so a stale old-scope create/update response
  resolving after the resync can't resurrect a now-hidden row that no new-scope
  delta would evict (P1). An authoritative applyDelta re-add un-fences; the next
  resync recomputes the set (re-upgrade clears it). Self-contained in the store —
  no epoch threading through the optimistic callers.
- scopeEpoch: bumped when a resync installs a new snapshot. Both reconcile loops
  capture it before each /items-changes and skip treating a response that raced a
  concurrent resync as caught-up, so a stale in-flight delta can't clear
  pendingResync without validating the pinned cursor (P2).

Regression test covers fence → reject stale upsert → authoritative re-add
un-fences → later edits accepted.

* fix(items): bump scope epoch before resync fetch (round 9 P2)

scopeEpoch advanced only after listIndex() returned, so a reconcile response
racing the fetch saw the old epoch and could clear the pendingResync the resync
set at start. Bump the epoch before the network await instead.
2026-07-13 22:46:55 -04:00

2710 lines
98 KiB
Go

package server
import (
"bytes"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
)
// doRequestWithHeaders is a thin variant of doRequest that lets a test set
// arbitrary request headers (e.g. Authorization) without spinning up a real
// auth flow. Mirrors doRequest's body marshalling + remote-addr behavior.
func doRequestWithHeaders(srv *Server, method, path string, body interface{}, headers map[string]string) *httptest.ResponseRecorder {
var bodyReader io.Reader
if body != nil {
data, _ := json.Marshal(body)
bodyReader = bytes.NewReader(data)
}
req := httptest.NewRequest(method, path, bodyReader)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
for k, v := range headers {
req.Header.Set(k, v)
}
req.RemoteAddr = "192.0.2.1:1234"
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
return rr
}
// createWSWithCollections creates a workspace and returns its slug.
// The workspace will have default collections seeded automatically.
func createWSWithCollections(t *testing.T, srv *Server) string {
t.Helper()
slug := createWSForTest(t, srv)
return slug
}
func TestCollectionCRUD(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// List collections — should have 4 defaults (tasks, ideas, plans, docs)
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list collections: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var colls []models.Collection
parseJSON(t, rr, &colls)
if len(colls) != 6 {
t.Fatalf("expected 6 default collections, got %d", len(colls))
}
// Create a custom collection
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]interface{}{
"name": "Bugs",
"icon": "bug",
"description": "Bug tracker",
"schema": `{"fields":[{"key":"severity","label":"Severity","type":"select","options":["low","medium","high"]}]}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var coll models.Collection
parseJSON(t, rr, &coll)
if coll.Slug != "bugs" {
t.Errorf("expected slug 'bugs', got %q", coll.Slug)
}
if coll.Name != "Bugs" {
t.Errorf("expected name 'Bugs', got %q", coll.Name)
}
// Get collection by slug
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/bugs", nil)
if rr.Code != http.StatusOK {
t.Fatalf("get collection: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Collection
parseJSON(t, rr, &fetched)
if fetched.ID != coll.ID {
t.Errorf("expected id %q, got %q", coll.ID, fetched.ID)
}
// Update collection
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/bugs", map[string]interface{}{
"name": "Bug Reports",
})
if rr.Code != http.StatusOK {
t.Fatalf("update collection: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var updated models.Collection
parseJSON(t, rr, &updated)
if updated.Name != "Bug Reports" {
t.Errorf("expected name 'Bug Reports', got %q", updated.Name)
}
// Delete custom collection (should work)
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/collections/bug-reports", nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete collection: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
// Delete default collection (should fail)
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/collections/tasks", nil)
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for deleting default collection, got %d: %s", rr.Code, rr.Body.String())
}
// Not found
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/nonexistent", nil)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rr.Code)
}
}
func TestCollectionCreateValidation(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Missing name
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]interface{}{})
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing name, got %d", rr.Code)
}
}
func TestItemCRUD(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create item in tasks collection
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Fix login bug",
"content": "Users can't log in with special chars in password",
"fields": `{"status":"open","priority":"high"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create item: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
if item.Title != "Fix login bug" {
t.Errorf("expected title 'Fix login bug', got %q", item.Title)
}
if item.CollectionSlug != "tasks" {
t.Errorf("expected collection slug 'tasks', got %q", item.CollectionSlug)
}
// Verify defaults were applied to fields
var fields map[string]interface{}
if err := json.Unmarshal([]byte(item.Fields), &fields); err != nil {
t.Fatalf("failed to unmarshal fields: %v", err)
}
if fields["status"] != "open" {
t.Errorf("expected status 'open', got %v", fields["status"])
}
if fields["priority"] != "high" {
t.Errorf("expected priority 'high', got %v", fields["priority"])
}
// Get item by slug
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+item.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.ID != item.ID {
t.Errorf("expected id %q, got %q", item.ID, fetched.ID)
}
// Update item fields
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug, map[string]interface{}{
"fields": `{"status":"in-progress","priority":"high"}`,
})
if rr.Code != http.StatusOK {
t.Fatalf("update item fields: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var updatedItem models.Item
parseJSON(t, rr, &updatedItem)
var updatedFields map[string]interface{}
json.Unmarshal([]byte(updatedItem.Fields), &updatedFields)
if updatedFields["status"] != "in-progress" {
t.Errorf("expected status 'in-progress', got %v", updatedFields["status"])
}
// Update item content
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug, map[string]interface{}{
"content": "Updated description of the login bug",
})
if rr.Code != http.StatusOK {
t.Fatalf("update item content: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// List items in collection
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/items", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list collection items: expected 200, got %d", rr.Code)
}
var collItems []models.Item
parseJSON(t, rr, &collItems)
if len(collItems) != 1 {
t.Errorf("expected 1 item in tasks, got %d", len(collItems))
}
// List all items cross-collection
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list items: expected 200, got %d", rr.Code)
}
var allItems []models.Item
parseJSON(t, rr, &allItems)
if len(allItems) != 1 {
t.Errorf("expected 1 total item, got %d", len(allItems))
}
// Delete (archive) item
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+item.Slug, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete item: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
// Should not appear in default list
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items", nil)
parseJSON(t, rr, &allItems)
if len(allItems) != 0 {
t.Errorf("expected 0 items after archive, got %d", len(allItems))
}
// Restore item
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/restore", nil)
if rr.Code != http.StatusOK {
t.Fatalf("restore item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Should appear again
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items", nil)
parseJSON(t, rr, &allItems)
if len(allItems) != 1 {
t.Errorf("expected 1 item after restore, got %d", len(allItems))
}
}
func TestListCollectionItemsResolvesRelationFieldFilterRefs(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
planResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/plans/items", map[string]interface{}{
"title": "Agent Workflow Intelligence",
"fields": `{"status":"active"}`,
})
if planResp.Code != http.StatusCreated {
t.Fatalf("create plan: expected 201, got %d: %s", planResp.Code, planResp.Body.String())
}
var plan models.Item
parseJSON(t, planResp, &plan)
if plan.Ref == "" {
t.Fatal("expected plan ref to be populated")
}
taskResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Add relation filter resolution",
"fields": `{"status":"open","parent":"` + plan.Ref + `"}`,
})
if taskResp.Code != http.StatusCreated {
t.Fatalf("create task: expected 201, got %d: %s", taskResp.Code, taskResp.Body.String())
}
otherTaskResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Unrelated task",
"fields": `{"status":"open"}`,
})
if otherTaskResp.Code != http.StatusCreated {
t.Fatalf("create unrelated task: expected 201, got %d: %s", otherTaskResp.Code, otherTaskResp.Body.String())
}
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/items?parent="+plan.Ref, nil)
if rr.Code != http.StatusOK {
t.Fatalf("list tasks by plan ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var items []models.Item
parseJSON(t, rr, &items)
if len(items) != 1 {
t.Fatalf("expected 1 task for plan ref filter, got %d", len(items))
}
if items[0].Title != "Add relation filter resolution" {
t.Fatalf("unexpected task returned: %q", items[0].Title)
}
}
func TestListItemsResolvesRelationFieldFilterRefsAcrossCollections(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
planResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/plans/items", map[string]interface{}{
"title": "Open Source Launch",
"fields": `{"status":"active"}`,
})
if planResp.Code != http.StatusCreated {
t.Fatalf("create plan: expected 201, got %d: %s", planResp.Code, planResp.Body.String())
}
var plan models.Item
parseJSON(t, planResp, &plan)
taskResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Document release filters",
"fields": `{"status":"open","parent":"` + plan.Ref + `"}`,
})
if taskResp.Code != http.StatusCreated {
t.Fatalf("create task: expected 201, got %d: %s", taskResp.Code, taskResp.Body.String())
}
docResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/docs/items", map[string]interface{}{
"title": "Release Notes",
"fields": `{"status":"draft","category":"launch"}`,
})
if docResp.Code != http.StatusCreated {
t.Fatalf("create doc: expected 201, got %d: %s", docResp.Code, docResp.Body.String())
}
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?parent="+plan.Ref, nil)
if rr.Code != http.StatusOK {
t.Fatalf("list items by plan ref: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var items []models.Item
parseJSON(t, rr, &items)
if len(items) != 1 {
t.Fatalf("expected 1 item for cross-collection plan ref filter, got %d", len(items))
}
if items[0].CollectionSlug != "tasks" {
t.Fatalf("expected task item, got collection %q", items[0].CollectionSlug)
}
}
func TestItemCreateValidation(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Missing title
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"content": "No title",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing title, got %d", rr.Code)
}
// Invalid field value (bad select option)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Test Task",
"fields": `{"status":"invalid_status"}`,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid field value, got %d: %s", rr.Code, rr.Body.String())
}
// Verify it returns a validation_error code
var errResp map[string]map[string]string
parseJSON(t, rr, &errResp)
if errResp["error"]["code"] != "validation_error" {
t.Errorf("expected error code 'validation_error', got %q", errResp["error"]["code"])
}
// Non-existent collection
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/nonexistent/items", map[string]interface{}{
"title": "Test",
})
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404 for non-existent collection, got %d", rr.Code)
}
// Item not found
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/nonexistent-slug", nil)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404, got %d", rr.Code)
}
}
func TestItemFieldDefaults(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create item without specifying fields — defaults should be applied
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Default Fields Task",
})
if rr.Code != http.StatusCreated {
t.Fatalf("create item: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
var fields map[string]interface{}
json.Unmarshal([]byte(item.Fields), &fields)
// Tasks schema has status default="open" and priority default="medium"
if fields["status"] != "open" {
t.Errorf("expected default status 'open', got %v", fields["status"])
}
if fields["priority"] != "medium" {
t.Errorf("expected default priority 'medium', got %v", fields["priority"])
}
}
func TestItemListWithFilters(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create multiple items with different statuses
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Open Task",
"fields": `{"status":"open","priority":"high"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "In Progress Task",
"fields": `{"status":"in-progress","priority":"medium"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Done Task",
"fields": `{"status":"done","priority":"low"}`,
})
// Filter by status
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?status=open", nil)
if rr.Code != http.StatusOK {
t.Fatalf("filter by status: expected 200, got %d", rr.Code)
}
var filtered []models.Item
parseJSON(t, rr, &filtered)
if len(filtered) != 1 {
t.Errorf("expected 1 open item, got %d", len(filtered))
}
// Filter by priority
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?priority=high", nil)
if rr.Code != http.StatusOK {
t.Fatalf("filter by priority: expected 200, got %d", rr.Code)
}
parseJSON(t, rr, &filtered)
if len(filtered) != 1 {
t.Errorf("expected 1 high priority item, got %d", len(filtered))
}
// Limit and offset
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?limit=2", nil)
if rr.Code != http.StatusOK {
t.Fatalf("limit: expected 200, got %d", rr.Code)
}
parseJSON(t, rr, &filtered)
if len(filtered) != 2 {
t.Errorf("expected 2 items with limit, got %d", len(filtered))
}
}
func TestItemSearch(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "OAuth Migration",
"content": "Migrate authentication to OAuth2 flow",
"fields": `{"status":"open"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Database Upgrade",
"content": "Upgrade PostgreSQL to version 16",
"fields": `{"status":"open"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items?search=OAuth", nil)
if rr.Code != http.StatusOK {
t.Fatalf("search: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var results []models.Item
parseJSON(t, rr, &results)
if len(results) != 1 {
t.Errorf("expected 1 search result, got %d", len(results))
}
if len(results) > 0 && results[0].Title != "OAuth Migration" {
t.Errorf("expected 'OAuth Migration', got %q", results[0].Title)
}
}
func TestItemLinks(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create two items
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Task A",
"fields": `{"status":"open"}`,
})
var itemA models.Item
parseJSON(t, rr, &itemA)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Task B",
"fields": `{"status":"open"}`,
})
var itemB models.Item
parseJSON(t, rr, &itemB)
// Create link from A to B
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+itemA.Slug+"/links", map[string]interface{}{
"target_id": itemB.ID,
"link_type": "blocks",
})
if rr.Code != http.StatusCreated {
t.Fatalf("create link: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var link models.ItemLink
parseJSON(t, rr, &link)
if link.SourceID != itemA.ID {
t.Errorf("expected source_id %q, got %q", itemA.ID, link.SourceID)
}
if link.TargetID != itemB.ID {
t.Errorf("expected target_id %q, got %q", itemB.ID, link.TargetID)
}
if link.LinkType != "blocks" {
t.Errorf("expected link_type 'blocks', got %q", link.LinkType)
}
// Get links for item A
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+itemA.Slug+"/links", nil)
if rr.Code != http.StatusOK {
t.Fatalf("get links: expected 200, got %d", rr.Code)
}
var links []models.ItemLink
parseJSON(t, rr, &links)
if len(links) != 1 {
t.Errorf("expected 1 link, got %d", len(links))
}
// Get links for item B (should also see the link)
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+itemB.Slug+"/links", nil)
if rr.Code != http.StatusOK {
t.Fatalf("get links B: expected 200, got %d", rr.Code)
}
parseJSON(t, rr, &links)
if len(links) != 1 {
t.Errorf("expected 1 link for B, got %d", len(links))
}
// Delete link
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/links/"+link.ID, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("delete link: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
// Verify link is gone
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+itemA.Slug+"/links", nil)
parseJSON(t, rr, &links)
if len(links) != 0 {
t.Errorf("expected 0 links after delete, got %d", len(links))
}
// Create link with missing target
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+itemA.Slug+"/links", map[string]interface{}{
"target_id": "",
})
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for missing target_id, got %d", rr.Code)
}
// Delete non-existent link
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/links/nonexistent-id", nil)
if rr.Code != http.StatusNotFound {
t.Errorf("expected 404 for non-existent link, got %d", rr.Code)
}
}
func TestGetItemIncludesDerivedClosureForSupersededItems(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Replacement Task",
"fields": `{"status":"done"}`,
})
var replacement models.Item
parseJSON(t, rr, &replacement)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Legacy Task",
"fields": `{"status":"open"}`,
})
var legacy models.Item
parseJSON(t, rr, &legacy)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+replacement.Slug+"/links", map[string]interface{}{
"target_id": legacy.ID,
"link_type": models.ItemLinkTypeSupersedes,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create supersedes link: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+legacy.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get legacy item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.DerivedClosure == nil {
t.Fatal("expected derived closure for superseded item")
}
if fetched.DerivedClosure.Kind != "superseded_by" {
t.Fatalf("expected superseded_by closure, got %q", fetched.DerivedClosure.Kind)
}
if !fetched.DerivedClosure.IsClosed {
t.Fatal("expected derived closure to mark item closed")
}
if len(fetched.DerivedClosure.RelatedItems) != 1 {
t.Fatalf("expected 1 related item, got %d", len(fetched.DerivedClosure.RelatedItems))
}
if fetched.DerivedClosure.RelatedItems[0].Ref != "TASK-1" {
t.Fatalf("expected related ref TASK-1, got %q", fetched.DerivedClosure.RelatedItems[0].Ref)
}
}
func TestGetItemIncludesDerivedClosureWhenSplitChildrenAreDone(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Parent Task",
"fields": `{"status":"open"}`,
})
var parent models.Item
parseJSON(t, rr, &parent)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Child One",
"fields": `{"status":"done"}`,
})
var childOne models.Item
parseJSON(t, rr, &childOne)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Child Two",
"fields": `{"status":"done"}`,
})
var childTwo models.Item
parseJSON(t, rr, &childTwo)
for _, child := range []models.Item{childOne, childTwo} {
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+child.Slug+"/links", map[string]interface{}{
"target_id": parent.ID,
"link_type": models.ItemLinkTypeSplitFrom,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create split_from link for %s: expected 201, got %d: %s", child.Title, rr.Code, rr.Body.String())
}
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+parent.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get parent item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.DerivedClosure == nil {
t.Fatal("expected derived closure for split parent")
}
if fetched.DerivedClosure.Kind != "split_into" {
t.Fatalf("expected split_into closure, got %q", fetched.DerivedClosure.Kind)
}
if len(fetched.DerivedClosure.RelatedItems) != 2 {
t.Fatalf("expected 2 related split children, got %d", len(fetched.DerivedClosure.RelatedItems))
}
}
func TestGetItemIncludesDerivedClosureForImplementedItems(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Implementation Task",
"fields": `{"status":"done"}`,
})
var implementer models.Item
parseJSON(t, rr, &implementer)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/ideas/items", map[string]interface{}{
"title": "Search UX Idea",
"fields": `{"status":"planned"}`,
})
var idea models.Item
parseJSON(t, rr, &idea)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+implementer.Slug+"/links", map[string]interface{}{
"target_id": idea.ID,
"link_type": models.ItemLinkTypeImplements,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create implements link: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+idea.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get implemented item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.DerivedClosure == nil {
t.Fatal("expected derived closure for implemented item")
}
if fetched.DerivedClosure.Kind != "implemented_by" {
t.Fatalf("expected implemented_by closure, got %q", fetched.DerivedClosure.Kind)
}
if len(fetched.DerivedClosure.RelatedItems) != 1 {
t.Fatalf("expected 1 implementing item, got %d", len(fetched.DerivedClosure.RelatedItems))
}
if fetched.DerivedClosure.RelatedItems[0].CollectionSlug != "tasks" {
t.Fatalf("expected implementing item collection slug tasks, got %q", fetched.DerivedClosure.RelatedItems[0].CollectionSlug)
}
}
func TestGetItemIncludesCodeContext(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Linked Task",
"fields": `{"status":"open"}`,
})
var item models.Item
parseJSON(t, rr, &item)
fields := `{"status":"open","github_pr":{"number":40,"url":"https://github.com/PerpetualSoftware/pad/pull/40","title":"Surface lineage relationships and derived closure for TASK-122","state":"MERGED","branch":"feat/task-122-lineage-display","repo":"PerpetualSoftware/pad","updated_at":"2026-04-02T14:46:09Z"}}`
updated, err := srv.store.UpdateItem(item.ID, models.ItemUpdate{Fields: &fields})
if err != nil {
t.Fatalf("update item fields: %v", err)
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+updated.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.CodeContext == nil {
t.Fatal("expected code context in item response")
}
if fetched.CodeContext.Branch != "feat/task-122-lineage-display" {
t.Fatalf("expected branch metadata, got %q", fetched.CodeContext.Branch)
}
if fetched.CodeContext.PullRequest == nil || fetched.CodeContext.PullRequest.Number != 40 {
t.Fatalf("expected PR metadata, got %#v", fetched.CodeContext.PullRequest)
}
}
func TestGetItemIncludesStructuredNotes(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Capture reasoning",
"fields": `{"status":"open"}`,
})
var item models.Item
parseJSON(t, rr, &item)
fields := `{"status":"open","implementation_notes":[{"id":"note-1","summary":"Add typed item metadata","details":"Expose the new arrays as top-level API fields","created_at":"2026-04-02T16:30:00Z","created_by":"agent"}],"decision_log":[{"id":"decision-1","decision":"Store notes in reserved field keys","rationale":"This keeps the first cut backward-compatible","created_at":"2026-04-02T16:35:00Z","created_by":"agent"}]}`
updated, err := srv.store.UpdateItem(item.ID, models.ItemUpdate{Fields: &fields})
if err != nil {
t.Fatalf("update item fields: %v", err)
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+updated.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if len(fetched.ImplementationNotes) != 1 {
t.Fatalf("expected 1 implementation note, got %#v", fetched.ImplementationNotes)
}
if fetched.ImplementationNotes[0].Summary != "Add typed item metadata" {
t.Fatalf("expected implementation note summary, got %q", fetched.ImplementationNotes[0].Summary)
}
if len(fetched.DecisionLog) != 1 {
t.Fatalf("expected 1 decision log entry, got %#v", fetched.DecisionLog)
}
if fetched.DecisionLog[0].Decision != "Store notes in reserved field keys" {
t.Fatalf("expected decision log entry, got %q", fetched.DecisionLog[0].Decision)
}
}
func TestGetItemIncludesConventionMetadata(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/conventions/items", map[string]interface{}{
"title": "Run tests before completing tasks",
"fields": `{"status":"active"}`,
})
var item models.Item
parseJSON(t, rr, &item)
fields := `{"status":"active","convention":{"category":"quality","trigger":"on-task-complete","surfaces":["all"],"enforcement":"must","commands":["go test ./...","make install"]}}`
updated, err := srv.store.UpdateItem(item.ID, models.ItemUpdate{Fields: &fields})
if err != nil {
t.Fatalf("update item fields: %v", err)
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+updated.Slug, nil)
if rr.Code != http.StatusOK {
t.Fatalf("get item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.Convention == nil {
t.Fatal("expected convention metadata in item response")
}
if fetched.Convention.Category != "quality" {
t.Fatalf("expected category quality, got %q", fetched.Convention.Category)
}
if fetched.Convention.Enforcement != "must" {
t.Fatalf("expected enforcement must, got %q", fetched.Convention.Enforcement)
}
if len(fetched.Convention.Commands) != 2 {
t.Fatalf("expected command references, got %#v", fetched.Convention.Commands)
}
}
func TestItemVersions(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create item with content
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/docs/items", map[string]interface{}{
"title": "Architecture Doc",
"content": "Initial architecture overview",
"fields": `{"status":"draft"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create item: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
// List versions
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+item.Slug+"/versions", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list versions: expected 200, got %d", rr.Code)
}
var versions []models.Version
parseJSON(t, rr, &versions)
if len(versions) != 1 {
t.Errorf("expected 1 initial version, got %d", len(versions))
}
}
func TestDashboard(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create some items for the dashboard to report on
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Task 1",
"fields": `{"status":"open","priority":"high"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Task 2",
"fields": `{"status":"done","priority":"medium"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/ideas/items", map[string]interface{}{
"title": "Idea 1",
"fields": `{"status":"new"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/dashboard", nil)
if rr.Code != http.StatusOK {
t.Fatalf("dashboard: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp DashboardResponse
parseJSON(t, rr, &resp)
// Verify summary
if resp.Summary.TotalItems != 3 {
t.Errorf("expected 3 total items, got %d", resp.Summary.TotalItems)
}
taskCounts, ok := resp.Summary.ByCollection["tasks"]
if !ok {
t.Fatal("expected 'tasks' in by_collection summary")
}
if taskCounts["open"] != 1 {
t.Errorf("expected 1 open task, got %d", taskCounts["open"])
}
if taskCounts["done"] != 1 {
t.Errorf("expected 1 done task, got %d", taskCounts["done"])
}
ideaCounts, ok := resp.Summary.ByCollection["ideas"]
if !ok {
t.Fatal("expected 'ideas' in by_collection summary")
}
if ideaCounts["new"] != 1 {
t.Errorf("expected 1 new idea, got %d", ideaCounts["new"])
}
// Verify structure has correct field types (even if empty)
if resp.ActivePlans == nil {
t.Error("expected active_plans to be non-nil")
}
if resp.Attention == nil {
t.Error("expected attention to be non-nil")
}
if resp.RecentActivity == nil {
t.Error("expected recent_activity to be non-nil")
}
if resp.SuggestedNext == nil {
t.Error("expected suggested_next to be non-nil")
}
}
func TestDashboardEmptyWorkspace(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/dashboard", nil)
if rr.Code != http.StatusOK {
t.Fatalf("dashboard: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp DashboardResponse
parseJSON(t, rr, &resp)
if resp.Summary.TotalItems != 0 {
t.Errorf("expected 0 total items, got %d", resp.Summary.TotalItems)
}
}
func TestItemUpdateFieldValidation(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create valid item
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Valid Task",
"fields": `{"status":"open"}`,
})
var item models.Item
parseJSON(t, rr, &item)
// Update with invalid status
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+item.Slug, map[string]interface{}{
"fields": `{"status":"invalid_option"}`,
})
if rr.Code != http.StatusBadRequest {
t.Errorf("expected 400 for invalid field update, got %d: %s", rr.Code, rr.Body.String())
}
}
func TestItemCrossCollectionListing(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Create items in different collections
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Task Item",
"fields": `{"status":"open"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/ideas/items", map[string]interface{}{
"title": "Idea Item",
"fields": `{"status":"new"}`,
})
doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/docs/items", map[string]interface{}{
"title": "Doc Item",
"fields": `{"status":"draft"}`,
})
// Cross-collection listing
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items", nil)
if rr.Code != http.StatusOK {
t.Fatalf("list all items: expected 200, got %d", rr.Code)
}
var items []models.Item
parseJSON(t, rr, &items)
if len(items) != 3 {
t.Errorf("expected 3 items across all collections, got %d", len(items))
}
// Verify each item has collection info
for _, item := range items {
if item.CollectionSlug == "" {
t.Errorf("item %q missing collection_slug", item.Title)
}
if item.CollectionName == "" {
t.Errorf("item %q missing collection_name", item.Title)
}
}
}
// TestCreateItemSourcePersistedFromAuth covers the fix for the bug Codex
// caught while reviewing TASK-862's PR: items created via the CLI were
// persisting with source='web' (the column default) instead of 'cli',
// because the handler decoded ItemCreate from the body — which has no
// Source set by the CLI client — and only consulted actorFromRequest
// AFTER persisting (for SSE / activity logs). With the fix, the handler
// now backfills input.Source from actorFromRequest before calling
// store.CreateItem, so items created with a Bearer auth header land as
// source='cli'. Without it, the dashboard's has_agent_activity signal
// (TASK-862) would never flip on for normal CLI usage.
//
// The bearer-auth test uses a real bootstrapped session token because
// the auth middleware validates token format (rejects "pad_anything"
// shapes with 401 before the handler ever runs).
func TestCreateItemSourcePersistedFromAuth(t *testing.T) {
t.Parallel()
srv := testServer(t)
sessionToken := bootstrapFirstUser(t, srv, "admin@example.com", "Admin")
// Create the workspace via the cookie path so subsequent tests have
// a real workspace to write into. CSRF is handled by doRequestWithCookie.
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces",
map[string]string{"name": "Source Test"}, sessionToken)
if rr.Code != http.StatusCreated {
t.Fatalf("create ws: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
t.Run("bearer auth header persists source as cli", func(t *testing.T) {
// Reusing the session token in the Authorization header simulates
// the CLI flow (CLI auth tokens are also surfaced this way through
// TokenAuth's padsess_ branch). actorFromRequest only checks
// Authorization-header presence to flip source, so this exercises
// the same branch.
rr := doRequestWithHeaders(srv, "POST",
"/api/v1/workspaces/"+ws.Slug+"/collections/tasks/items",
map[string]interface{}{
"title": "From CLI",
"fields": `{"status":"open"}`,
},
map[string]string{"Authorization": "Bearer " + sessionToken},
)
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
if item.Source != "cli" {
t.Fatalf("expected source=cli when Authorization header is present, got %q", item.Source)
}
})
t.Run("cookie session persists source as web", func(t *testing.T) {
rr := doRequestWithCookie(srv, "POST",
"/api/v1/workspaces/"+ws.Slug+"/collections/tasks/items",
map[string]interface{}{
"title": "From Web",
"fields": `{"status":"open"}`,
},
sessionToken,
)
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
if item.Source != "web" {
t.Fatalf("expected source=web with cookie session and no Authorization header, got %q", item.Source)
}
})
t.Run("explicit source in body wins over auth-derived", func(t *testing.T) {
// e.g. an agent acting through the CLI explicitly marks itself as
// 'skill'. We must respect that and not clobber it with the
// auth-derived 'cli' default.
rr := doRequestWithHeaders(srv, "POST",
"/api/v1/workspaces/"+ws.Slug+"/collections/tasks/items",
map[string]interface{}{
"title": "From Skill",
"fields": `{"status":"open"}`,
"source": "skill",
},
map[string]string{"Authorization": "Bearer " + sessionToken},
)
if rr.Code != http.StatusCreated {
t.Fatalf("expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var item models.Item
parseJSON(t, rr, &item)
if item.Source != "skill" {
t.Fatalf("expected source=skill when explicitly set in body, got %q", item.Source)
}
})
}
// TestPatchItem_FlexibleFieldsShape covers BUG-1144: PATCH must accept
// `fields` (and `tags`) as a nested JSON object/array in addition to
// the historical JSON-encoded-string shape. Wrong shapes return a
// clean domain-level error instead of leaked Go unmarshal internals.
func TestPatchItem_FlexibleFieldsShape(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Seed an item to patch.
createResp := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "BUG-1144 fixture",
"fields": `{"status":"open","priority":"medium"}`,
})
if createResp.Code != http.StatusCreated {
t.Fatalf("seed: expected 201, got %d: %s", createResp.Code, createResp.Body.String())
}
var seeded models.Item
parseJSON(t, createResp, &seeded)
t.Run("fields as nested object (the BUG-1144 repro)", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"fields": map[string]interface{}{
"status": "in-progress",
"priority": "high",
},
})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for nested-object fields, got %d: %s", rr.Code, rr.Body.String())
}
var got models.Item
parseJSON(t, rr, &got)
var fields map[string]interface{}
if err := json.Unmarshal([]byte(got.Fields), &fields); err != nil {
t.Fatalf("response fields not valid JSON: %v", err)
}
if fields["status"] != "in-progress" || fields["priority"] != "high" {
t.Fatalf("update didn't apply: %#v", fields)
}
})
t.Run("fields as JSON-encoded string still works (back-compat)", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"fields": `{"status":"done","priority":"high"}`,
})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for stringified fields, got %d: %s", rr.Code, rr.Body.String())
}
var got models.Item
parseJSON(t, rr, &got)
var fields map[string]interface{}
json.Unmarshal([]byte(got.Fields), &fields)
if fields["status"] != "done" {
t.Fatalf("update didn't apply: %#v", fields)
}
})
t.Run("fields wrong type returns domain-level error, not Go internals", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"fields": 42,
})
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for numeric fields, got %d: %s", rr.Code, rr.Body.String())
}
body := rr.Body.String()
// Must NOT leak Go internals.
if bytes.Contains([]byte(body), []byte("Go struct field")) {
t.Fatalf("response leaked Go internals: %s", body)
}
if bytes.Contains([]byte(body), []byte("ItemUpdate.fields")) {
t.Fatalf("response leaked Go field name: %s", body)
}
// Must guide the caller toward a fix. The error JSON has its
// inner double-quotes escaped, so check for the escaped form.
if !bytes.Contains([]byte(body), []byte(`\"fields\" must be a JSON object`)) {
t.Fatalf("response missing domain-level message: %s", body)
}
})
t.Run("tags as nested array", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"tags": []string{"alpha", "beta"},
})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for nested-array tags, got %d: %s", rr.Code, rr.Body.String())
}
var got models.Item
parseJSON(t, rr, &got)
var tags []string
if err := json.Unmarshal([]byte(got.Tags), &tags); err != nil {
t.Fatalf("response tags not valid JSON: %v", err)
}
if len(tags) != 2 || tags[0] != "alpha" || tags[1] != "beta" {
t.Fatalf("update didn't apply: %#v", tags)
}
})
t.Run("tags as JSON-encoded string still works", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"tags": `["gamma"]`,
})
if rr.Code != http.StatusOK {
t.Fatalf("expected 200 for stringified tags, got %d: %s", rr.Code, rr.Body.String())
}
})
t.Run("tags wrong type returns domain-level error", func(t *testing.T) {
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+seeded.Ref, map[string]interface{}{
"tags": map[string]interface{}{"x": 1},
})
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for object tags, got %d: %s", rr.Code, rr.Body.String())
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`\"tags\" must be a JSON array`)) {
t.Fatalf("response missing domain-level tags message: %s", rr.Body.String())
}
})
}
// itemsIndexBody mirrors the server-side response wrapper for /items-index.
// Kept local to the test file so the public handler doesn't need to export it.
type itemsIndexBody struct {
Items []models.Item `json:"items"`
Total int `json:"total"`
Cursor string `json:"cursor"`
IncludesUnparentedMetadata bool `json:"includes_unparented_metadata"`
}
// TestListItemsIndex_SkinnyProjectionAndShape covers the foundational
// behavior of the local-first read model bootstrap endpoint (TASK-1344):
// - response shape: {items, total, cursor}
// - content body excluded (skinny projection)
// - cursor reflects MAX(seq) across the result set (TASK-1353)
// - per-row seq is populated and non-zero
// - core projected fields populate (ref, collection_slug, fields, …)
//
// Sort order is exercised separately by TestListItemsIndex_SortByUpdatedAt,
// which forces distinct timestamps — store.now() has RFC3339 second
// resolution, so two creates inside the same second tie on updated_at
// and the ID tiebreaker (not creation order) decides the row order.
func TestListItemsIndex_SkinnyProjectionAndShape(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "First task",
"content": "Body text that MUST NOT be returned by the index endpoint",
"fields": `{"status":"open","priority":"high"}`,
})
createItem(t, srv, slug, "ideas", map[string]interface{}{
"title": "Second idea",
"content": "Another body that the skinny projection should skip",
"fields": `{"status":"new"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if resp.Total != 2 {
t.Fatalf("expected total=2, got %d", resp.Total)
}
if len(resp.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(resp.Items))
}
if resp.Cursor == "" || resp.Cursor == "0" {
t.Fatalf("expected cursor to be populated for non-empty workspace, got %q", resp.Cursor)
}
// Cursor must be the decimal-encoded MAX(seq) across the returned
// rows (TASK-1353). Per-row seq is populated by the skinny scan.
cursorSeq, err := strconv.ParseInt(resp.Cursor, 10, 64)
if err != nil {
t.Fatalf("cursor not decimal-encoded int: %q (err=%v)", resp.Cursor, err)
}
var maxSeq int64
for _, it := range resp.Items {
if it.Seq == 0 {
t.Errorf("items[%s]: seq must be non-zero", it.Ref)
}
if it.Seq > maxSeq {
maxSeq = it.Seq
}
}
if cursorSeq != maxSeq {
t.Fatalf("cursor mismatch: got %d, want MAX(seq)=%d", cursorSeq, maxSeq)
}
// Skinny projection: content body excluded for every row.
for i, it := range resp.Items {
if it.Content != "" {
t.Fatalf("items[%d] (%s): expected empty content, got %q", i, it.Ref, it.Content)
}
if it.Ref == "" {
t.Errorf("items[%d]: missing computed ref", i)
}
if it.CollectionSlug == "" {
t.Errorf("items[%d]: missing collection_slug", i)
}
if it.Fields == "" {
t.Errorf("items[%d]: missing fields JSON", i)
}
}
}
// TestListItemsIndex_SortByUpdatedAt forces a >1s gap between the two
// items so they land in distinct RFC3339 buckets, then asserts the
// updated_at DESC ordering. The sleep is the price of admission for
// testing a sort whose tiebreaker (id ASC) would otherwise win — see
// the comment on TestListItemsIndex_SkinnyProjectionAndShape.
func TestListItemsIndex_SortByUpdatedAt(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
older := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Older",
"fields": `{"status":"open"}`,
})
// store.now() uses RFC3339 (second precision). Sleep just over 1s
// so the second create lands in a distinct timestamp bucket.
time.Sleep(1100 * time.Millisecond)
newer := createItem(t, srv, slug, "ideas", map[string]interface{}{
"title": "Newer",
"fields": `{"status":"new"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if len(resp.Items) != 2 {
t.Fatalf("expected 2 items, got %d", len(resp.Items))
}
if resp.Items[0].ID != newer.ID {
t.Fatalf("expected newer item first; got %q (want %q)", resp.Items[0].ID, newer.ID)
}
if resp.Items[1].ID != older.ID {
t.Fatalf("expected older item second; got %q (want %q)", resp.Items[1].ID, older.ID)
}
}
// TestListItemsIndex_EmptyWorkspace covers the edge case the cursor
// is documented to handle: no items → cursor "0".
func TestListItemsIndex_EmptyWorkspace(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index empty: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if resp.Total != 0 {
t.Fatalf("expected total=0, got %d", resp.Total)
}
if len(resp.Items) != 0 {
t.Fatalf("expected empty items slice, got %d", len(resp.Items))
}
// JSON contract: items must marshal as [] (not null) so the client can
// rely on Array semantics.
if !bytes.Contains(rr.Body.Bytes(), []byte(`"items":[]`)) {
t.Fatalf("expected empty items array, body=%s", rr.Body.String())
}
if resp.Cursor != "0" {
t.Fatalf("expected cursor=\"0\" on empty workspace, got %q", resp.Cursor)
}
}
// TestListItemsIndex_EmptyResultFallsBackToWorkspaceMax verifies the
// cursor-fallback path (TASK-1353 acceptance): when a filtered query
// returns zero rows but the workspace itself is non-empty, the cursor
// must hold the workspace's MAX(seq) so the client's next
// /items-changes?since=cursor poll starts at the right floor rather
// than replaying every prior mutation from 0.
func TestListItemsIndex_EmptyResultFallsBackToWorkspaceMax(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Populate the workspace so MAX(seq) is non-zero.
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "T1",
"fields": `{"status":"open"}`,
})
createItem(t, srv, slug, "ideas", map[string]interface{}{
"title": "I1",
"fields": `{"status":"new"}`,
})
// Establish the baseline MAX(seq) via the unfiltered call.
rrAll := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rrAll.Code != http.StatusOK {
t.Fatalf("items-index all: expected 200, got %d: %s", rrAll.Code, rrAll.Body.String())
}
var all itemsIndexBody
parseJSON(t, rrAll, &all)
if all.Cursor == "" || all.Cursor == "0" {
t.Fatalf("baseline cursor should be non-zero, got %q", all.Cursor)
}
// Now request a collection that exists but has no items — the response
// slice is empty, but the workspace MAX(seq) fallback should still
// produce the same cursor.
rrFiltered := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index?collection=docs", nil)
if rrFiltered.Code != http.StatusOK {
t.Fatalf("items-index docs: expected 200, got %d: %s", rrFiltered.Code, rrFiltered.Body.String())
}
var filtered itemsIndexBody
parseJSON(t, rrFiltered, &filtered)
if filtered.Total != 0 {
t.Fatalf("expected total=0 for empty collection filter, got %d", filtered.Total)
}
if filtered.Cursor != all.Cursor {
t.Fatalf("empty-result cursor should equal workspace MAX(seq) baseline %q, got %q", all.Cursor, filtered.Cursor)
}
if _, err := strconv.ParseInt(filtered.Cursor, 10, 64); err != nil {
t.Fatalf("fallback cursor not decimal-encoded int: %q", filtered.Cursor)
}
}
// TestListItemsIndex_CursorMonotonicAcrossMutations confirms that the
// cursor advances after every items mutation and can be re-used as the
// `since` parameter on a follow-up /items-changes call (cursor contract
// for PLAN-1343 Phase 2).
func TestListItemsIndex_CursorMonotonicAcrossMutations(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "C1",
"fields": `{"status":"open"}`,
})
rr1 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
var resp1 itemsIndexBody
parseJSON(t, rr1, &resp1)
c1, _ := strconv.ParseInt(resp1.Cursor, 10, 64)
if c1 == 0 {
t.Fatalf("expected non-zero cursor after first create, got %q", resp1.Cursor)
}
createItem(t, srv, slug, "ideas", map[string]interface{}{
"title": "C2",
"fields": `{"status":"new"}`,
})
rr2 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
var resp2 itemsIndexBody
parseJSON(t, rr2, &resp2)
c2, _ := strconv.ParseInt(resp2.Cursor, 10, 64)
if c2 <= c1 {
t.Fatalf("cursor should advance after second create: c1=%d, c2=%d", c1, c2)
}
}
// TestListItemsIndex_CollectionFilter covers ?collection=<slug>.
func TestListItemsIndex_CollectionFilter(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "A task",
"fields": `{"status":"open"}`,
})
createItem(t, srv, slug, "ideas", map[string]interface{}{
"title": "An idea",
"fields": `{"status":"new"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index?collection=tasks", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index?collection=tasks: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if resp.Total != 1 {
t.Fatalf("expected total=1 task, got %d", resp.Total)
}
if resp.Items[0].CollectionSlug != "tasks" {
t.Fatalf("expected collection_slug=tasks, got %q", resp.Items[0].CollectionSlug)
}
}
// TestListItemsIndex_ParentEnrichment confirms parent_link_id / parent_ref
// are populated for child items (same enrichment path the existing items
// list uses).
func TestListItemsIndex_ParentEnrichment(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
plan := createItem(t, srv, slug, "plans", map[string]interface{}{
"title": "Parent plan",
"fields": `{"status":"active"}`,
})
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Child task",
"fields": `{"status":"open","parent":"` + plan.Ref + `"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index?collection=tasks", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if len(resp.Items) != 1 {
t.Fatalf("expected 1 child task, got %d", len(resp.Items))
}
child := resp.Items[0]
if child.ParentLinkID == "" {
t.Fatalf("expected parent_link_id to be populated, got empty")
}
if child.ParentRef != plan.Ref {
t.Fatalf("expected parent_ref=%q, got %q", plan.Ref, child.ParentRef)
}
}
// TestListItemsIndex_ExcludesArchivedByDefault confirms the IncludeArchived
// gate matches handleListItems' default behavior.
func TestListItemsIndex_ExcludesArchivedByDefault(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
keep := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Keep me",
"fields": `{"status":"open"}`,
})
archive := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Archive me",
"fields": `{"status":"open"}`,
})
rr := doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+archive.Slug, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("archive: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsIndexBody
parseJSON(t, rr, &resp)
if resp.Total != 1 || resp.Items[0].ID != keep.ID {
t.Fatalf("expected only the live item to remain; got %d items, first=%q want=%q",
resp.Total, firstID(resp.Items), keep.ID)
}
// With include_archived=true, both items must come back.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index?include_archived=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index include_archived: expected 200, got %d", rr.Code)
}
parseJSON(t, rr, &resp)
if resp.Total != 2 {
t.Fatalf("expected total=2 with include_archived, got %d", resp.Total)
}
}
// itemsChangesBody mirrors the server-side response wrapper for
// /items-changes. Local to the test file so the public handler
// doesn't need to export it. The Changes element embeds Item via
// the same `models.Item + Deleted bool` shape.
type itemsChangesBody struct {
Changes []struct {
models.Item
Deleted bool `json:"deleted"`
} `json:"changes"`
Cursor string `json:"cursor"`
IncludesUnparentedMetadata bool `json:"includes_unparented_metadata"`
}
// TestListItemsChanges_FullDeltaFromZero covers the foundational
// behavior of the delta-fetch endpoint (TASK-1354): three creates,
// `?since=0`, all three returned in ascending seq order, every row
// `deleted:false`, cursor = max seq.
func TestListItemsChanges_FullDeltaFromZero(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "A", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "B", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "C", "fields": `{"status":"open"}`})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=0", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-changes since=0: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsChangesBody
parseJSON(t, rr, &resp)
if len(resp.Changes) != 3 {
t.Fatalf("expected 3 changes, got %d", len(resp.Changes))
}
// Ascending seq order.
for i := 1; i < len(resp.Changes); i++ {
if resp.Changes[i].Seq <= resp.Changes[i-1].Seq {
t.Fatalf("changes not in ascending seq order: changes[%d].Seq=%d <= changes[%d].Seq=%d", i, resp.Changes[i].Seq, i-1, resp.Changes[i-1].Seq)
}
if resp.Changes[i].Deleted {
t.Fatalf("change[%d] unexpectedly marked deleted", i)
}
}
// Cursor == MAX(seq).
cursorSeq, err := strconv.ParseInt(resp.Cursor, 10, 64)
if err != nil {
t.Fatalf("cursor not decimal int: %q", resp.Cursor)
}
if cursorSeq != resp.Changes[len(resp.Changes)-1].Seq {
t.Fatalf("cursor (%d) should equal max seq in response (%d)", cursorSeq, resp.Changes[len(resp.Changes)-1].Seq)
}
// Skinny projection: no `content` body in the wire payload.
if bytes.Contains(rr.Body.Bytes(), []byte(`"content":"some long body"`)) {
t.Fatalf("changes response leaked content body: %s", rr.Body.String())
}
}
// TestListItemsChanges_IncrementalUpdateAndDelete walks the typical
// resume flow: create three, snapshot cursor, update one, soft-delete
// one. A poll at the snapshot cursor returns exactly those two rows
// with `deleted` set correctly.
func TestListItemsChanges_IncrementalUpdateAndDelete(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
a := createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "A", "fields": `{"status":"open"}`})
b := createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "B", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "C", "fields": `{"status":"open"}`})
// Snapshot the cursor via /items-index after the three creates.
rrSnap := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
var snap itemsIndexBody
parseJSON(t, rrSnap, &snap)
since := snap.Cursor
// Update A's title.
newTitle := "A-updated"
rrU := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/items/"+a.Slug, map[string]interface{}{"title": newTitle})
if rrU.Code != http.StatusOK {
t.Fatalf("update A: expected 200, got %d: %s", rrU.Code, rrU.Body.String())
}
// Soft-delete B.
rrD := doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+b.Slug, nil)
if rrD.Code != http.StatusNoContent {
t.Fatalf("delete B: expected 204, got %d: %s", rrD.Code, rrD.Body.String())
}
// Delta poll resumes from the snapshot cursor.
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since="+since, nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-changes resume: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsChangesBody
parseJSON(t, rr, &resp)
if len(resp.Changes) != 2 {
t.Fatalf("expected 2 changes (update + delete), got %d", len(resp.Changes))
}
// Find each by ID and check the deleted flag.
var seenA, seenB bool
for _, ch := range resp.Changes {
switch ch.ID {
case a.ID:
if ch.Deleted {
t.Errorf("A updated: expected deleted=false, got true")
}
if ch.Title != newTitle {
t.Errorf("A updated: expected title=%q, got %q", newTitle, ch.Title)
}
seenA = true
case b.ID:
if !ch.Deleted {
t.Errorf("B soft-deleted: expected deleted=true, got false")
}
seenB = true
}
}
if !seenA || !seenB {
t.Fatalf("missing expected rows: seenA=%v seenB=%v", seenA, seenB)
}
}
// TestListItemsChanges_CursorRoundtripsCleanly proves "no overlap or
// gap": after running the delta once, re-polling with the returned
// cursor as `since` returns zero rows.
func TestListItemsChanges_CursorRoundtripsCleanly(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "A", "fields": `{"status":"open"}`})
createItem(t, srv, slug, "tasks", map[string]interface{}{"title": "B", "fields": `{"status":"open"}`})
rr1 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=0", nil)
var first itemsChangesBody
parseJSON(t, rr1, &first)
if len(first.Changes) != 2 {
t.Fatalf("first poll: expected 2 changes, got %d", len(first.Changes))
}
rr2 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since="+first.Cursor, nil)
var second itemsChangesBody
parseJSON(t, rr2, &second)
if len(second.Changes) != 0 {
t.Fatalf("second poll at returned cursor should be empty, got %d changes", len(second.Changes))
}
if second.Cursor != first.Cursor {
t.Fatalf("empty poll should preserve cursor: got %q want %q", second.Cursor, first.Cursor)
}
}
// TestListItemsChanges_LimitTruncatesAndCursorResumes covers the
// `?limit=N` truncation contract: the response holds N rows; cursor
// equals seq of the last returned row so the client can resume with
// no overlap or gap.
func TestListItemsChanges_LimitTruncatesAndCursorResumes(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
for i := 0; i < 5; i++ {
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "item-" + strconv.Itoa(i),
"fields": `{"status":"open"}`,
})
}
// First page: limit=2 returns 2 rows; cursor sits at the last
// row's seq.
rr1 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=0&limit=2", nil)
var page1 itemsChangesBody
parseJSON(t, rr1, &page1)
if len(page1.Changes) != 2 {
t.Fatalf("page1: expected 2 rows under limit=2, got %d", len(page1.Changes))
}
cursorSeq, err := strconv.ParseInt(page1.Cursor, 10, 64)
if err != nil {
t.Fatalf("page1 cursor not int: %q", page1.Cursor)
}
if cursorSeq != page1.Changes[len(page1.Changes)-1].Seq {
t.Fatalf("page1 cursor (%d) should equal last row seq (%d)", cursorSeq, page1.Changes[len(page1.Changes)-1].Seq)
}
// Resume from the cursor: remaining 3 rows in ascending seq order.
rr2 := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since="+page1.Cursor+"&limit=10", nil)
var page2 itemsChangesBody
parseJSON(t, rr2, &page2)
if len(page2.Changes) != 3 {
t.Fatalf("page2: expected 3 remaining rows, got %d", len(page2.Changes))
}
// No overlap: every page2 seq must be strictly greater than the
// page1 cursor.
for _, ch := range page2.Changes {
if ch.Seq <= cursorSeq {
t.Errorf("page2 row seq %d <= page1 cursor %d (overlap)", ch.Seq, cursorSeq)
}
}
}
// TestListItemsChanges_InvalidParams rejects bad `since` / `limit`
// values with 400 instead of silently returning the entire workspace.
func TestListItemsChanges_InvalidParams(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=abc", nil)
if rr.Code != http.StatusBadRequest {
t.Fatalf("invalid since: expected 400, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=-5", nil)
if rr.Code != http.StatusBadRequest {
t.Fatalf("negative since: expected 400, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?limit=0", nil)
if rr.Code != http.StatusBadRequest {
t.Fatalf("limit=0: expected 400, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?limit=-1", nil)
if rr.Code != http.StatusBadRequest {
t.Fatalf("limit=-1: expected 400, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestListItemsChanges_EmptyWorkspace returns no rows and preserves
// the caller's cursor.
func TestListItemsChanges_EmptyWorkspace(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=0", nil)
if rr.Code != http.StatusOK {
t.Fatalf("empty workspace: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var resp itemsChangesBody
parseJSON(t, rr, &resp)
if len(resp.Changes) != 0 {
t.Fatalf("expected 0 changes on empty workspace, got %d", len(resp.Changes))
}
if resp.Cursor != "0" {
t.Fatalf("expected cursor=\"0\" on empty workspace with since=0, got %q", resp.Cursor)
}
// Custom since should round-trip when no changes exist.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-changes?since=42", nil)
parseJSON(t, rr, &resp)
if resp.Cursor != "42" {
t.Fatalf("expected cursor to round-trip caller's since=42, got %q", resp.Cursor)
}
}
// TestListItemsIndex_DoesNotShadowItemSlug confirms /items-index lives in
// a non-conflicting URL space — an item titled "Index" (slug "index") still
// resolves through /items/{itemSlug}, while /items-index serves the new
// index wrapper. This is the contract that drove the path choice: keeping
// the endpoint outside the /items/{itemSlug} subtree means no item slug
// can ever shadow it (or vice versa). See Codex round 1 [P2] on PR #486.
func TestListItemsIndex_DoesNotShadowItemSlug(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
indexItem := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Index",
"fields": `{"status":"open"}`,
})
if indexItem.Slug != "index" {
t.Fatalf("expected slug 'index' for title 'Index', got %q", indexItem.Slug)
}
// /items-index → index wrapper.
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items-index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items-index: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`"items":[`)) {
t.Fatalf("expected wrapped response, got %s", rr.Body.String())
}
// /items/index → the item titled "Index", same as before this change.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/index", nil)
if rr.Code != http.StatusOK {
t.Fatalf("items/index detail: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var fetched models.Item
parseJSON(t, rr, &fetched)
if fetched.ID != indexItem.ID {
t.Fatalf("expected item ID %q at /items/index, got %q", indexItem.ID, fetched.ID)
}
}
// TestCollectionCheckboxProgress covers the markdown-checkbox progress
// endpoint that pairs with /items-index (TASK-1349). Verifies SQL
// LENGTH/REPLACE arithmetic produces the same per-item counts the
// client used to compute from item.content before /items-index made
// content unavailable in list view.
func TestCollectionCheckboxProgress(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Item with 2 open + 1 done checkbox.
mixed := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "Has checklist",
"content": "Do this:\n- [ ] alpha\n- [x] beta\n- [ ] gamma\n",
"fields": `{"status":"open"}`,
})
// Item with no checkboxes — must be excluded from the response.
createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "No checklist",
"content": "Just prose, nothing to count here.",
"fields": `{"status":"open"}`,
})
// Item with only done checkboxes — total == done.
allDone := createItem(t, srv, slug, "tasks", map[string]interface{}{
"title": "All done",
"content": "- [x] one\n- [x] two\n",
"fields": `{"status":"done"}`,
})
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/checkbox-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("checkbox-progress: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
type progressRow struct {
ItemID string `json:"item_id"`
Total int `json:"total"`
Done int `json:"done"`
}
var resp []progressRow
parseJSON(t, rr, &resp)
if len(resp) != 2 {
t.Fatalf("expected 2 rows (mixed + allDone), got %d: %+v", len(resp), resp)
}
byID := map[string]progressRow{}
for _, r := range resp {
byID[r.ItemID] = r
}
if r, ok := byID[mixed.ID]; !ok {
t.Fatalf("mixed item missing from response")
} else if r.Total != 3 || r.Done != 1 {
t.Fatalf("mixed item: expected total=3, done=1, got total=%d, done=%d", r.Total, r.Done)
}
if r, ok := byID[allDone.ID]; !ok {
t.Fatalf("allDone item missing from response")
} else if r.Total != 2 || r.Done != 2 {
t.Fatalf("allDone item: expected total=2, done=2, got total=%d, done=%d", r.Total, r.Done)
}
// Unknown collection → 404.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/nonexistent/checkbox-progress", nil)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown collection, got %d: %s", rr.Code, rr.Body.String())
}
// Empty result (collection has no items with checkboxes) → 200 + [].
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/ideas/checkbox-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("checkbox-progress empty: expected 200, got %d", rr.Code)
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`[]`)) {
t.Fatalf("expected empty array body, got %s", rr.Body.String())
}
// Archive `allDone` and confirm it drops out of the default response
// but reappears with ?include_archived=true. Mirrors the Archived
// toggle on the collection page (Codex round 2 [P2] on PR #491).
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+allDone.Slug, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("archive allDone: expected 204, got %d", rr.Code)
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/checkbox-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("checkbox-progress default: expected 200, got %d", rr.Code)
}
resp = nil
parseJSON(t, rr, &resp)
for _, r := range resp {
if r.ItemID == allDone.ID {
t.Fatalf("archived item should not appear in default checkbox-progress response")
}
}
if len(resp) != 1 {
t.Fatalf("expected 1 row (mixed) after archiving allDone, got %d", len(resp))
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/checkbox-progress?include_archived=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("checkbox-progress include_archived: expected 200, got %d", rr.Code)
}
resp = nil
parseJSON(t, rr, &resp)
if len(resp) != 2 {
t.Fatalf("expected 2 rows with include_archived, got %d", len(resp))
}
}
func firstID(items []models.Item) string {
if len(items) == 0 {
return ""
}
return items[0].ID
}
// TestCollectionChildProgress covers the /collections/{coll}/child-progress
// endpoint introduced in BUG-1509. It verifies:
// - Happy path: items with linked children show correct total/done counts.
// - Items without linked children appear with total=0, done=0.
// - Unknown collection → 404.
// - Empty collection → 200 + [].
// - Visibility gate: a restricted member without access to the collection
// receives an empty response (not child counts for a hidden collection).
func TestCollectionChildProgress(t *testing.T) {
t.Parallel()
srv := testServer(t)
slug := createWSWithCollections(t, srv)
// Resolve workspace for direct store calls.
ws, err := srv.store.GetWorkspaceBySlug(slug)
if err != nil || ws == nil {
t.Fatalf("GetWorkspaceBySlug: %v", err)
}
// ── Happy path setup ─────────────────────────────────────────────────────
// parentA: a task with two linked children (one done, one open).
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Parent A",
"fields": `{"status":"open"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create parentA: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var parentA models.Item
parseJSON(t, rr, &parentA)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Child Done",
"fields": `{"status":"done"}`,
})
var childDone models.Item
parseJSON(t, rr, &childDone)
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Child Open",
"fields": `{"status":"open"}`,
})
var childOpen models.Item
parseJSON(t, rr, &childOpen)
// Link both children to parentA via "parent" link type.
for _, child := range []models.Item{childDone, childOpen} {
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+child.Slug+"/links", map[string]interface{}{
"target_id": parentA.ID,
"link_type": models.ItemLinkTypeParent,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create parent link for %s: expected 201, got %d: %s", child.Title, rr.Code, rr.Body.String())
}
}
// parentB: a task with no linked children (checkbox-only control).
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Parent B — checkboxes only",
"content": "- [ ] alpha\n- [x] beta\n",
"fields": `{"status":"open"}`,
})
var parentB models.Item
parseJSON(t, rr, &parentB)
// ── Happy path assertions ─────────────────────────────────────────────────
type progressRow struct {
ItemID string `json:"item_id"`
Total int `json:"total"`
Done int `json:"done"`
}
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/child-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("child-progress: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var rows []progressRow
parseJSON(t, rr, &rows)
byID := map[string]progressRow{}
for _, r := range rows {
byID[r.ItemID] = r
}
// parentA must have total=2 (two linked children), done=1 (childDone).
if r, ok := byID[parentA.ID]; !ok {
t.Fatalf("parentA missing from child-progress response")
} else if r.Total != 2 || r.Done != 1 {
t.Fatalf("parentA: expected total=2, done=1; got total=%d, done=%d", r.Total, r.Done)
}
// parentB has no linked children → total=0, done=0 (present, but zero).
if r, ok := byID[parentB.ID]; !ok {
t.Fatalf("parentB missing from child-progress response (should be present with total=0)")
} else if r.Total != 0 || r.Done != 0 {
t.Fatalf("parentB: expected total=0, done=0; got total=%d, done=%d", r.Total, r.Done)
}
// ── Unknown collection → 404 ──────────────────────────────────────────────
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/nonexistent/child-progress", nil)
if rr.Code != http.StatusNotFound {
t.Fatalf("expected 404 for unknown collection, got %d: %s", rr.Code, rr.Body.String())
}
// ── Empty collection (ideas — no items, no children) → 200 + [] ──────────
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/ideas/child-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("child-progress empty: expected 200, got %d", rr.Code)
}
if !bytes.Contains(rr.Body.Bytes(), []byte(`[]`)) {
t.Fatalf("expected empty array for ideas, got %s", rr.Body.String())
}
// ── include_archived: archived parent with children ───────────────────────
// Archive parentA (soft-delete). Without include_archived it must
// disappear from the default response; with it the row reappears.
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+parentA.Slug, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("archive parentA: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
// Default (no include_archived) — parentA must be absent.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/child-progress", nil)
if rr.Code != http.StatusOK {
t.Fatalf("child-progress after archive: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
rows = nil
parseJSON(t, rr, &rows)
for _, r := range rows {
if r.ItemID == parentA.ID {
t.Fatalf("archived parentA should not appear in default child-progress response")
}
}
// With include_archived=true — parentA must reappear with total=2, done=1.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/child-progress?include_archived=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("child-progress include_archived: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
rows = nil
parseJSON(t, rr, &rows)
byID = map[string]progressRow{}
for _, r := range rows {
byID[r.ItemID] = r
}
if r, ok := byID[parentA.ID]; !ok {
t.Fatalf("archived parentA missing from include_archived=true response")
} else if r.Total != 2 || r.Done != 1 {
t.Fatalf("archived parentA: expected total=2, done=1; got total=%d, done=%d", r.Total, r.Done)
}
// ── include_archived: done-semantics must use archived parents' child
// collections (childrenDoneFiltersForCollection gap, codex round 3) ──────
//
// Create a custom "Widgets" collection whose done field is `state` with
// terminal value "shipped" — NOT the default `status` terminals. If
// childrenDoneFiltersForCollection still filters parents with
// `p.deleted_at IS NULL` while the main query includes archived parents,
// the widgets collection never enters the done-filter map, and the child
// done-check falls back to default status terminals → done=0 (bug).
// With the fix, the filter-discovery query also sees archived parents →
// widgets enters the map → done=1 (correct).
//
// Crucially: NO live task parent links into widgets. That would mask the
// bug by pulling widgets into the filter map via the live-parent path.
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections", map[string]interface{}{
"name": "Widgets",
// board_group_by=state causes TerminalValuesForDoneField to use the
// state field's terminal_options instead of the status field.
"schema": `{"fields":[{"key":"state","label":"State","type":"select","options":["open","shipped"],"terminal_options":["shipped"],"default":"open"}]}`,
"settings": `{"board_group_by":"state"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create widgets collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var widgetsColl models.Collection
parseJSON(t, rr, &widgetsColl)
// widgetDone: state=shipped → done under widgets' semantics.
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/widgets/items", map[string]interface{}{
"title": "Widget Done",
"fields": `{"state":"shipped"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create widget done: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var widgetDone models.Item
parseJSON(t, rr, &widgetDone)
// widgetOpen: state=open → not done.
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/widgets/items", map[string]interface{}{
"title": "Widget Open",
"fields": `{"state":"open"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create widget open: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var widgetOpen models.Item
parseJSON(t, rr, &widgetOpen)
// widgetParent: a task (in the tasks collection) that has NO live task
// children — only the two widget children. Will be archived immediately.
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/tasks/items", map[string]interface{}{
"title": "Widget Parent (tasks)",
"fields": `{"status":"done"}`,
})
if rr.Code != http.StatusCreated {
t.Fatalf("create widgetParent: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
var widgetParent models.Item
parseJSON(t, rr, &widgetParent)
// Link both widgets as children of widgetParent.
for _, w := range []models.Item{widgetDone, widgetOpen} {
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/items/"+w.Slug+"/links", map[string]interface{}{
"target_id": widgetParent.ID,
"link_type": models.ItemLinkTypeParent,
})
if rr.Code != http.StatusCreated {
t.Fatalf("link %s → widgetParent: expected 201, got %d: %s", w.Title, rr.Code, rr.Body.String())
}
}
// Archive widgetParent. Now NO live task parent links into widgets.
rr = doRequest(srv, "DELETE", "/api/v1/workspaces/"+slug+"/items/"+widgetParent.Slug, nil)
if rr.Code != http.StatusNoContent {
t.Fatalf("archive widgetParent: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
// With include_archived=true: widgetParent must appear with total=2,
// done=1 (shipped widget). If childrenDoneFiltersForCollection still
// excludes archived parents, widgets drops from the filter map and the
// done check falls back to default status terminals — which would give
// done=0 because neither widget has a `status` field set to a terminal
// value (they have `state` instead). done=1 proves the fix is in effect.
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/child-progress?include_archived=true", nil)
if rr.Code != http.StatusOK {
t.Fatalf("widgets include_archived: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
rows = nil
parseJSON(t, rr, &rows)
byID = map[string]progressRow{}
for _, r := range rows {
byID[r.ItemID] = r
}
if r, ok := byID[widgetParent.ID]; !ok {
t.Fatalf("archived widgetParent missing from include_archived=true response")
} else if r.Total != 2 || r.Done != 1 {
t.Fatalf("archived widgetParent: expected total=2 done=1 (state-based done); got total=%d done=%d (if done=0 the filter-discovery bug is live)", r.Total, r.Done)
}
// ── Visibility gate: restricted member without tasks access ───────────────
// Create a regular member, restrict them to only the "ideas" collection,
// then confirm that GET /child-progress for "tasks" returns [] (not a
// data leak of parentA's child counts).
restrictedUser, err := srv.store.CreateUser(models.UserCreate{
Email: "restricted@example.com", Name: "Restricted", Username: "restricted-user",
Password: "pw-test-12345",
})
if err != nil {
t.Fatalf("CreateUser restricted: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, restrictedUser.ID, "editor"); err != nil {
t.Fatalf("AddWorkspaceMember: %v", err)
}
// Resolve ideas collection ID for the access grant via the store directly
// (the HTTP endpoint may require auth once a workspace owner exists).
ideasColl, err := srv.store.GetCollectionBySlug(ws.ID, "ideas")
if err != nil || ideasColl == nil {
t.Fatalf("GetCollectionBySlug ideas: %v", err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, restrictedUser.ID, "specific", []string{ideasColl.ID}); err != nil {
t.Fatalf("SetMemberCollectionAccess: %v", err)
}
token, err := srv.store.CreateSession(restrictedUser.ID, "go-test", "192.0.2.1", "", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
// Restricted user must get an empty response for tasks, not parentA's data.
rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/tasks/child-progress", nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("restricted/tasks child-progress: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var restricted []progressRow
parseJSON(t, rr, &restricted)
if len(restricted) != 0 {
t.Fatalf("restricted member should see no task child-progress; got %d rows: %+v", len(restricted), restricted)
}
// Restricted user CAN see ideas (within their grant).
rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/ideas/child-progress", nil, token)
if rr.Code != http.StatusOK {
t.Fatalf("restricted/ideas child-progress: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestListItems_AdminBearer_RestrictedMemberIsScoped pins BUG-1917 for
// handleListItems: a platform admin who is only a restricted member
// (collection_access "specific") of a workspace gets the full unrestricted
// item list over a cookie session (the pre-existing web UI admin
// affordance), but must be scoped to their actual membership over a bearer
// token (PAT/CLI/OAuth). Mirrors handlers_admin_bearer_gate_test.go's
// fixture idiom.
func TestListItems_AdminBearer_RestrictedMemberIsScoped(t *testing.T) {
t.Parallel()
srv := testServer(t)
admin, err := srv.store.CreateUser(models.UserCreate{
Email: "admin@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
})
if err != nil {
t.Fatalf("create admin: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "ItemsBearer", OwnerID: admin.ID})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "editor"); err != nil {
t.Fatalf("add member: %v", err)
}
schema := `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`
visible, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Visible", Slug: "visible", Prefix: "VIS", Schema: schema,
})
if err != nil {
t.Fatalf("create visible collection: %v", err)
}
hidden, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Hidden", Slug: "hidden", Prefix: "HID", Schema: schema,
})
if err != nil {
t.Fatalf("create hidden collection: %v", err)
}
if _, err := srv.store.CreateItem(ws.ID, visible.ID, models.ItemCreate{
Title: "Visible item", Fields: `{"status":"open"}`,
}); err != nil {
t.Fatalf("create visible item: %v", err)
}
if _, err := srv.store.CreateItem(ws.ID, hidden.ID, models.ItemCreate{
Title: "Hidden item", Fields: `{"status":"open"}`,
}); err != nil {
t.Fatalf("create hidden item: %v", err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, admin.ID, "specific", []string{visible.ID}); err != nil {
t.Fatalf("set member collection access: %v", err)
}
tok, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{
Name: "admin-pat", WorkspaceID: ws.ID,
}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
// Bearer path — scoped to the visible collection only.
rr := doRequestWithHeaders(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/items", nil,
map[string]string{"Authorization": "Bearer " + tok.Token})
if rr.Code != http.StatusOK {
t.Fatalf("bearer items: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var bearerItems []models.Item
parseJSON(t, rr, &bearerItems)
if len(bearerItems) != 1 || bearerItems[0].CollectionSlug != "visible" {
t.Fatalf("bearer admin items should be scoped to 1 visible-collection item, got %d: %+v", len(bearerItems), bearerItems)
}
// Cookie path — unrestricted (the pre-existing web UI admin affordance).
sessTok, err := srv.store.CreateSession(admin.ID, "web-test", "192.0.2.1", "", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
rr = doRequestWithCookie(srv, "GET", "/api/v1/workspaces/"+ws.Slug+"/items", nil, sessTok)
if rr.Code != http.StatusOK {
t.Fatalf("cookie items: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
var cookieItems []models.Item
parseJSON(t, rr, &cookieItems)
if len(cookieItems) != 2 {
t.Fatalf("cookie admin items should be unrestricted (2 items), got %d: %+v", len(cookieItems), cookieItems)
}
}
// TestCreateItem_AdminBearer_RestrictedMemberIsScoped pins the WRITE-path
// side of BUG-1917: handleCreateItem gates its collection-visibility check
// (handlers_items.go, "Check collection visibility") through the same
// visibleCollectionIDs helper, so the fix applies symmetrically to writes,
// not just the read surfaces. A bearer-authed admin who is a restricted
// member (no grant on the target collection) attempting to create an item
// there gets the same 404 an equivalent restricted member would see;
// creating in their visible collection still succeeds. A cookie session
// keeps the pre-existing unrestricted admin affordance on both collections.
func TestCreateItem_AdminBearer_RestrictedMemberIsScoped(t *testing.T) {
t.Parallel()
srv := testServer(t)
admin, err := srv.store.CreateUser(models.UserCreate{
Email: "admin@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
})
if err != nil {
t.Fatalf("create admin: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "CreateItemBearer", OwnerID: admin.ID})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "editor"); err != nil {
t.Fatalf("add member: %v", err)
}
schema := `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`
visible, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Visible", Slug: "visible", Prefix: "VIS", Schema: schema,
})
if err != nil {
t.Fatalf("create visible collection: %v", err)
}
if _, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Hidden", Slug: "hidden", Prefix: "HID", Schema: schema,
}); err != nil {
t.Fatalf("create hidden collection: %v", err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, admin.ID, "specific", []string{visible.ID}); err != nil {
t.Fatalf("set member collection access: %v", err)
}
tok, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{
Name: "admin-pat", WorkspaceID: ws.ID,
}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
// Bearer admin creating in the HIDDEN collection (outside their
// membership grant) must be rejected, same as a restricted member.
rr := doRequestWithHeaders(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/collections/hidden/items",
map[string]interface{}{"title": "Should be blocked", "fields": `{"status":"open"}`},
map[string]string{"Authorization": "Bearer " + tok.Token},
)
if rr.Code != http.StatusNotFound {
t.Fatalf("bearer admin create in hidden collection: expected 404, got %d: %s", rr.Code, rr.Body.String())
}
// Bearer admin creating in their VISIBLE collection still succeeds.
rr = doRequestWithHeaders(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/collections/visible/items",
map[string]interface{}{"title": "Should succeed", "fields": `{"status":"open"}`},
map[string]string{"Authorization": "Bearer " + tok.Token},
)
if rr.Code != http.StatusCreated {
t.Fatalf("bearer admin create in visible collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
// Cookie admin — unrestricted, can create in the hidden collection too
// (the pre-existing web UI admin affordance).
sessTok, err := srv.store.CreateSession(admin.ID, "web-test", "192.0.2.1", "", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
rr = doRequestWithCookie(srv, "POST", "/api/v1/workspaces/"+ws.Slug+"/collections/hidden/items",
map[string]interface{}{"title": "Cookie admin can create here", "fields": `{"status":"open"}`}, sessTok)
if rr.Code != http.StatusCreated {
t.Fatalf("cookie admin create in hidden collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
}
}
// bearerGateItemFixture seeds a platform admin who is also a restricted
// workspace member (collection_access="specific", scoped to "visible"
// only), one item in the "hidden" collection, and one in "visible".
// Shared setup for BUG-1918's single-item bearer-gate tests
// (GET/PATCH/DELETE/export), mirroring
// TestCreateItem_AdminBearer_RestrictedMemberIsScoped's fixture shape.
type bearerGateItemFixture struct {
srv *Server
ws *models.Workspace
admin *models.User
hiddenCollID string
visibleCollID string
hiddenItem *models.Item
visibleItem *models.Item
bearerToken string
sessionToken string
}
// newItem creates a fresh item in the given (hidden/visible) collection.
// Used by tests that mutate the fixture's default hiddenItem/visibleItem
// (e.g. archiving them) and need an untouched pair for a follow-up
// assertion.
func (f *bearerGateItemFixture) newItem(t *testing.T, collID, title string) *models.Item {
t.Helper()
item, err := f.srv.store.CreateItem(f.ws.ID, collID, models.ItemCreate{
Title: title, Fields: `{"status":"open"}`,
})
if err != nil {
t.Fatalf("create item %q: %v", title, err)
}
return item
}
func newBearerGateItemFixture(t *testing.T) *bearerGateItemFixture {
t.Helper()
srv := testServer(t)
admin, err := srv.store.CreateUser(models.UserCreate{
Email: "admin@example.com", Name: "Admin", Password: "correct-horse-battery-staple", Role: "admin",
})
if err != nil {
t.Fatalf("create admin: %v", err)
}
ws, err := srv.store.CreateWorkspace(models.WorkspaceCreate{Name: "ItemGateBearer", OwnerID: admin.ID})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, admin.ID, "editor"); err != nil {
t.Fatalf("add member: %v", err)
}
schema := `{"fields":[{"key":"status","type":"select","options":["open","done"],"default":"open"}]}`
visible, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Visible", Slug: "visible", Prefix: "VIS", Schema: schema,
})
if err != nil {
t.Fatalf("create visible collection: %v", err)
}
hidden, err := srv.store.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Hidden", Slug: "hidden", Prefix: "HID", Schema: schema,
})
if err != nil {
t.Fatalf("create hidden collection: %v", err)
}
visibleItem, err := srv.store.CreateItem(ws.ID, visible.ID, models.ItemCreate{
Title: "Visible item", Fields: `{"status":"open"}`,
})
if err != nil {
t.Fatalf("create visible item: %v", err)
}
hiddenItem, err := srv.store.CreateItem(ws.ID, hidden.ID, models.ItemCreate{
Title: "Hidden item", Fields: `{"status":"open"}`,
})
if err != nil {
t.Fatalf("create hidden item: %v", err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, admin.ID, "specific", []string{visible.ID}); err != nil {
t.Fatalf("set member collection access: %v", err)
}
tok, err := srv.store.CreateAPIToken(admin.ID, models.APITokenCreate{
Name: "admin-pat", WorkspaceID: ws.ID,
}, 0, 0)
if err != nil {
t.Fatalf("CreateAPIToken: %v", err)
}
sessTok, err := srv.store.CreateSession(admin.ID, "web-test", "192.0.2.1", "", 24*time.Hour)
if err != nil {
t.Fatalf("CreateSession: %v", err)
}
return &bearerGateItemFixture{
srv: srv, ws: ws, admin: admin,
hiddenCollID: hidden.ID, visibleCollID: visible.ID,
hiddenItem: hiddenItem, visibleItem: visibleItem,
bearerToken: tok.Token, sessionToken: sessTok,
}
}
func (f *bearerGateItemFixture) bearerHeaders() map[string]string {
return map[string]string{"Authorization": "Bearer " + f.bearerToken}
}
// TestGetItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection pins
// the read-path side of BUG-1918: checkItemVisible's unconditional
// admin bypass previously let a bearer-authed admin fetch ANY item by
// ref, even one in a collection they can't see per their own
// member_collection_access — bypassing BUG-1917's list-level scoping
// entirely for anyone who can guess (or enumerate) a ref.
func TestGetItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection(t *testing.T) {
t.Parallel()
f := newBearerGateItemFixture(t)
rr := doRequestWithHeaders(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug, nil, f.bearerHeaders())
if rr.Code != http.StatusNotFound {
t.Fatalf("bearer admin GET hidden item: expected 404, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequestWithHeaders(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.visibleItem.Slug, nil, f.bearerHeaders())
if rr.Code != http.StatusOK {
t.Fatalf("bearer admin GET visible item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Cookie admin — unrestricted, sees both (the pre-existing web UI
// admin affordance).
rr = doRequestWithCookie(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug, nil, f.sessionToken)
if rr.Code != http.StatusOK {
t.Fatalf("cookie admin GET hidden item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
rr = doRequestWithCookie(f.srv, "GET", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.visibleItem.Slug, nil, f.sessionToken)
if rr.Code != http.StatusOK {
t.Fatalf("cookie admin GET visible item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestUpdateItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection
// pins the write-path side of BUG-1918. PATCH must 404 at
// requireItemVisible before requireEditPermission's role/grant check
// ever runs — the hidden-collection item must be indistinguishable from
// a genuinely nonexistent one.
func TestUpdateItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection(t *testing.T) {
t.Parallel()
f := newBearerGateItemFixture(t)
rr := doRequestWithHeaders(f.srv, "PATCH", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug,
map[string]interface{}{"title": "Renamed"}, f.bearerHeaders())
if rr.Code != http.StatusNotFound {
t.Fatalf("bearer admin PATCH hidden item: expected 404, got %d: %s", rr.Code, rr.Body.String())
}
// Visible-collection item: admin is an editor member there, so the
// edit succeeds.
rr = doRequestWithHeaders(f.srv, "PATCH", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.visibleItem.Slug,
map[string]interface{}{"title": "Renamed"}, f.bearerHeaders())
if rr.Code != http.StatusOK {
t.Fatalf("bearer admin PATCH visible item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
// Cookie admin — unrestricted, can PATCH the hidden item too.
rr = doRequestWithCookie(f.srv, "PATCH", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug,
map[string]interface{}{"title": "Cookie renamed"}, f.sessionToken)
if rr.Code != http.StatusOK {
t.Fatalf("cookie admin PATCH hidden item: expected 200, got %d: %s", rr.Code, rr.Body.String())
}
}
// TestDeleteItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection
// completes the write-path sweep for BUG-1918 (DELETE).
func TestDeleteItem_AdminBearer_RestrictedMemberBlockedOnHiddenCollection(t *testing.T) {
t.Parallel()
f := newBearerGateItemFixture(t)
rr := doRequestWithHeaders(f.srv, "DELETE", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug, nil, f.bearerHeaders())
if rr.Code != http.StatusNotFound {
t.Fatalf("bearer admin DELETE hidden item: expected 404, got %d: %s", rr.Code, rr.Body.String())
}
// Cookie admin — unrestricted, can delete the hidden item.
rr = doRequestWithCookie(f.srv, "DELETE", "/api/v1/workspaces/"+f.ws.Slug+"/items/"+f.hiddenItem.Slug, nil, f.sessionToken)
if rr.Code != http.StatusNoContent {
t.Fatalf("cookie admin DELETE hidden item: expected 204, got %d: %s", rr.Code, rr.Body.String())
}
}