mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-25 11:52:08 +00:00
c72fe5a663
* 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.
2710 lines
98 KiB
Go
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())
|
|
}
|
|
}
|