mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-23 02:53:31 +00:00
a5b93c17c9
* feat(api): add /items-changes?since=<seq> delta endpoint (TASK-1354) Adds the delta-fetch sibling of /items-index for the local-first read model (PLAN-1343 / DOC-1342 design decision #1). Clients track the workspace-scoped monotonic seq cursor returned by /items-index (TASK-1353) and poll /items-changes?since=<cursor> to apply just the rows that have mutated — without re-downloading the entire workspace. ## Endpoint GET /api/v1/workspaces/{ws}/items-changes?since=<seq>&limit=<n> - `since`: exclusive seq lower bound (returns `seq > since`). Defaults to 0 → full delta == /items-index modulo ordering. Bad input → 400. - `limit`: cap on rows. Defaults to 5000, clamped to 50000. Bad input → 400. ## Response { "changes": [...skinny rows with `deleted: bool`...], "cursor": "<decimal MAX(seq) or unchanged since when empty>" } Soft-deleted rows propagate (no `deleted_at IS NULL` filter on the backing scan) so a delta consumer can remove them from its local index without a second roundtrip. Parent metadata enrichment matches /items-index: the underlying GetItem filters soft-deleted parents so we never leak parent title/ref for an archived parent. Cursor contract: - Sorted ASC by seq → re-passing the response's cursor as `since` on the next poll is no-overlap, no-gap (strictly monotonic seq invariant from TASK-1352). - Empty response preserves the caller's `since` so position isn't lost. - Truncated-by-limit responses set cursor to the last row's seq. ## Tests - FullDeltaFromZero — three creates, since=0, ascending seq, every row deleted=false, cursor=MAX(seq). - IncrementalUpdateAndDelete — typical resume flow: snapshot cursor, mutate, delta returns exactly the mutated + tombstoned rows with the right `deleted` flag. - CursorRoundtripsCleanly — empty-poll after consuming, cursor preserved. - LimitTruncatesAndCursorResumes — paging contract holds end to end with no overlap. - InvalidParams — bad since / limit values rejected with 400. - EmptyWorkspace — cursor round-trips caller's since unchanged. ## Web TypeScript: `ItemChangeRow = ItemIndexRow & { deleted: boolean }`, `ItemChangesResponse = { changes, cursor }`. API client gains `api.items.changes(ws, sinceCursor, opts?)` with the same defensive content-strip as listIndex so a stray `content: ""` key from a Go zero-value can never clobber the canonical store. Parent: PLAN-1343. Depends on TASK-1352 (seq column) and TASK-1353 (seq cursor on /items-index). Unblocks the future client-side localIndex.applyDelta integration task. * fix(api): surface tombstones for item-grant users in /items-changes per Codex review (round 1) Codex round 1 caught that handleListItemsChanges was building its ItemIDs filter from guestResourceFilter, which itself uses GuestVisibleResources whose item-grant query filters out soft-deleted items. The result: a guest or restricted member with an item-level grant on a single item would see that ID disappear from the lookup as soon as the item was soft-deleted — and /items-changes would never emit a `deleted:true` tombstone, so the client would keep the stale row in its local index forever. Fix: - New Store.GuestVisibleResourcesIncludeDeleted that drops the `i.deleted_at IS NULL` / `c.deleted_at IS NULL` filters on both collection and item grants so tombstone IDs flow through. - New Server.guestResourceFilterIncludeDeletedItems delegate pointing at the new store helper. Implementation is shared with the live variant via guestResourceFilterCore so the member-collection-access + system-collection merge logic stays in one place. - handleListItemsChanges swaps to the include-deleted variant. Test: TestGuestVisibleResourcesIncludeDeleted_SurfacesTombstones covers both variants side-by-side — live drops the soft-deleted grant, include-deleted preserves it. * fix(store): assign per-row unique seqs in MigrateItemFieldValues per Codex review (round 2) Codex round 2 caught that the bulk UPDATE inside MigrateItemFieldValues gave every affected row the SAME MAX(seq)+1. A /items-changes?limit=N poll that cut through that equal-seq group would advance the cursor to the shared seq, and the next `seq > cursor` poll would silently miss the rest of the group — the cursor contract requires strict monotonicity. Switched to a per-row loop inside the migration transaction so every UPDATE re-reads MAX(seq) and each affected row ends up with a strictly unique seq. The workspace advisory lock makes the read-modify-write race-free on Postgres; SQLite's single-writer rule handles it implicitly. Trade-off: O(N) statements instead of O(1) for the bulk path. Option-rename is an admin one-off so the cost is acceptable (~1s/1000 rows on a warm SQLite connection). If future use cases demand a larger row budget, a single-statement UPDATE..FROM with ROW_NUMBER() CTE assigning per-row seqs would also work. Test: TestMigrateItemFieldValues_PerRowUniqueSeq confirms 5 rows in a single migration step all get unique seqs.
3393 lines
115 KiB
Go
3393 lines
115 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"testing"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// --- Collection helpers ---
|
|
|
|
func createTestCollection(t *testing.T, s *Store, workspaceID, name string) *models.Collection {
|
|
t.Helper()
|
|
col, err := s.CreateCollection(workspaceID, models.CollectionCreate{
|
|
Name: name,
|
|
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create collection: %v", err)
|
|
}
|
|
return col
|
|
}
|
|
|
|
func createTestItem(t *testing.T, s *Store, workspaceID, collectionID, title, content string) *models.Item {
|
|
t.Helper()
|
|
item, err := s.CreateItem(workspaceID, collectionID, models.ItemCreate{
|
|
Title: title,
|
|
Content: content,
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("failed to create item: %v", err)
|
|
}
|
|
return item
|
|
}
|
|
|
|
// --- Collection Tests ---
|
|
|
|
func TestCollectionCRUD(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
// Create
|
|
col, err := s.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Tasks",
|
|
Icon: "check-square",
|
|
Description: "Track work items",
|
|
Schema: `{"fields":[]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection error: %v", err)
|
|
}
|
|
if col.Name != "Tasks" {
|
|
t.Errorf("expected name 'Tasks', got %q", col.Name)
|
|
}
|
|
if col.Slug != "tasks" {
|
|
t.Errorf("expected slug 'tasks', got %q", col.Slug)
|
|
}
|
|
if col.Icon != "check-square" {
|
|
t.Errorf("expected icon 'check-square', got %q", col.Icon)
|
|
}
|
|
|
|
// Get
|
|
got, err := s.GetCollection(col.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetCollection error: %v", err)
|
|
}
|
|
if got == nil || got.ID != col.ID {
|
|
t.Error("GetCollection returned wrong collection")
|
|
}
|
|
|
|
// Get by slug
|
|
got, err = s.GetCollectionBySlug(ws.ID, "tasks")
|
|
if err != nil {
|
|
t.Fatalf("GetCollectionBySlug error: %v", err)
|
|
}
|
|
if got == nil || got.ID != col.ID {
|
|
t.Error("GetCollectionBySlug returned wrong collection")
|
|
}
|
|
|
|
// Update
|
|
newName := "My Tasks"
|
|
newIcon := "list"
|
|
updated, err := s.UpdateCollection(col.ID, models.CollectionUpdate{
|
|
Name: &newName,
|
|
Icon: &newIcon,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateCollection error: %v", err)
|
|
}
|
|
if updated.Name != "My Tasks" {
|
|
t.Errorf("expected updated name 'My Tasks', got %q", updated.Name)
|
|
}
|
|
if updated.Icon != "list" {
|
|
t.Errorf("expected updated icon 'list', got %q", updated.Icon)
|
|
}
|
|
if updated.Slug != "my-tasks" {
|
|
t.Errorf("expected slug 'my-tasks' after rename, got %q", updated.Slug)
|
|
}
|
|
|
|
// List
|
|
list, err := s.ListCollections(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListCollections error: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Errorf("expected 1 collection, got %d", len(list))
|
|
}
|
|
|
|
// Delete
|
|
err = s.DeleteCollection(col.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteCollection error: %v", err)
|
|
}
|
|
|
|
// Should not appear in list
|
|
list, _ = s.ListCollections(ws.ID)
|
|
if len(list) != 0 {
|
|
t.Error("deleted collection still appears in list")
|
|
}
|
|
|
|
// Should not be found by slug
|
|
got, _ = s.GetCollectionBySlug(ws.ID, "my-tasks")
|
|
if got != nil {
|
|
t.Error("deleted collection still found by slug")
|
|
}
|
|
}
|
|
|
|
func TestCollectionDeleteDefaultRefused(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
// Create a default collection
|
|
col, err := s.CreateCollection(ws.ID, models.CollectionCreate{
|
|
Name: "Tasks",
|
|
IsDefault: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection error: %v", err)
|
|
}
|
|
|
|
// Attempt to delete — should fail
|
|
err = s.DeleteCollection(col.ID)
|
|
if err == nil {
|
|
t.Fatal("expected error when deleting default collection")
|
|
}
|
|
if err.Error() != "cannot delete default collection" {
|
|
t.Errorf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCollectionListWithItemCounts(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
createTestItem(t, s, ws.ID, col.ID, "Task 1", "content")
|
|
createTestItem(t, s, ws.ID, col.ID, "Task 2", "content")
|
|
|
|
list, err := s.ListCollections(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListCollections error: %v", err)
|
|
}
|
|
if len(list) != 1 {
|
|
t.Fatalf("expected 1 collection, got %d", len(list))
|
|
}
|
|
if list[0].ItemCount != 2 {
|
|
t.Errorf("expected item_count=2, got %d", list[0].ItemCount)
|
|
}
|
|
}
|
|
|
|
func TestSeedDefaultCollections(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
err := s.SeedDefaultCollections(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("SeedDefaultCollections error: %v", err)
|
|
}
|
|
|
|
list, err := s.ListCollections(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListCollections error: %v", err)
|
|
}
|
|
if len(list) != 6 {
|
|
t.Errorf("expected 6 default collections, got %d", len(list))
|
|
}
|
|
|
|
// Verify slugs
|
|
slugs := make(map[string]bool)
|
|
for _, c := range list {
|
|
slugs[c.Slug] = true
|
|
if !c.IsDefault {
|
|
t.Errorf("expected collection %q to be default", c.Slug)
|
|
}
|
|
}
|
|
for _, expected := range []string{"tasks", "ideas", "plans", "docs", "conventions", "playbooks"} {
|
|
if !slugs[expected] {
|
|
t.Errorf("expected default collection %q", expected)
|
|
}
|
|
}
|
|
|
|
// Seed again — should be idempotent
|
|
err = s.SeedDefaultCollections(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("SeedDefaultCollections (idempotent) error: %v", err)
|
|
}
|
|
list, _ = s.ListCollections(ws.ID)
|
|
if len(list) != 6 {
|
|
t.Errorf("expected 6 after re-seed, got %d", len(list))
|
|
}
|
|
}
|
|
|
|
func TestSeedCollectionsFromTemplateAddsConventionsRoleField(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Template Test")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "scrum"); err != nil {
|
|
t.Fatalf("SeedCollectionsFromTemplate error: %v", err)
|
|
}
|
|
|
|
coll, err := s.GetCollectionBySlug(ws.ID, "conventions")
|
|
if err != nil {
|
|
t.Fatalf("GetCollectionBySlug error: %v", err)
|
|
}
|
|
if coll == nil {
|
|
t.Fatal("expected conventions collection")
|
|
}
|
|
|
|
keys := schemaFieldKeys(t, coll.Schema)
|
|
foundRole := false
|
|
for _, key := range keys {
|
|
if key == "role" {
|
|
foundRole = true
|
|
break
|
|
}
|
|
}
|
|
if !foundRole {
|
|
t.Fatalf("expected conventions schema to include role field, got %v", keys)
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateSeedsStarterPack verifies that the software
|
|
// templates' starter conventions + playbooks are materialized as items in
|
|
// the newly-created conventions/playbooks collections. This is what makes
|
|
// templates "batteries included" rather than empty shells.
|
|
func TestSeedCollectionsFromTemplateSeedsStarterPack(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Starter Pack")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("seed error: %v", err)
|
|
}
|
|
|
|
convColl, err := s.GetCollectionBySlug(ws.ID, "conventions")
|
|
if err != nil || convColl == nil {
|
|
t.Fatalf("conventions collection missing: %v", err)
|
|
}
|
|
convItems, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if err != nil {
|
|
t.Fatalf("list conventions items: %v", err)
|
|
}
|
|
if len(convItems) == 0 {
|
|
t.Errorf("expected starter conventions to be seeded, got 0 items")
|
|
}
|
|
|
|
playColl, err := s.GetCollectionBySlug(ws.ID, "playbooks")
|
|
if err != nil || playColl == nil {
|
|
t.Fatalf("playbooks collection missing: %v", err)
|
|
}
|
|
playItems, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "playbooks"})
|
|
if err != nil {
|
|
t.Fatalf("list playbooks items: %v", err)
|
|
}
|
|
if len(playItems) == 0 {
|
|
t.Errorf("expected starter playbooks to be seeded, got 0 items")
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateHiring verifies end-to-end that the hiring
|
|
// template creates the right collections and seeds its starter pack.
|
|
func TestSeedCollectionsFromTemplateHiring(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Hiring")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "hiring"); err != nil {
|
|
t.Fatalf("seed hiring template: %v", err)
|
|
}
|
|
|
|
for _, slug := range []string{"requisitions", "candidates", "interview-loops", "feedback", "docs", "conventions", "playbooks"} {
|
|
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
|
if err != nil || coll == nil {
|
|
t.Errorf("hiring workspace missing collection %q (err=%v)", slug, err)
|
|
}
|
|
}
|
|
|
|
// Starter conventions land in the conventions collection.
|
|
convs, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if err != nil {
|
|
t.Fatalf("list conventions: %v", err)
|
|
}
|
|
if len(convs) == 0 {
|
|
t.Error("expected hiring starter conventions to be seeded, got 0")
|
|
}
|
|
|
|
// Starter playbooks land in the playbooks collection.
|
|
plays, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "playbooks"})
|
|
if err != nil {
|
|
t.Fatalf("list playbooks: %v", err)
|
|
}
|
|
if len(plays) == 0 {
|
|
t.Error("expected hiring starter playbooks to be seeded, got 0")
|
|
}
|
|
|
|
// Seed items land in their named collections.
|
|
reqs, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "requisitions"})
|
|
if len(reqs) == 0 {
|
|
t.Error("expected hiring seed Requisition, got 0")
|
|
}
|
|
cands, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "candidates"})
|
|
if len(cands) == 0 {
|
|
t.Error("expected hiring seed Candidate, got 0")
|
|
}
|
|
|
|
// Explicit prefixes land on the collections (issue IDs like REQ-1,
|
|
// CAND-1 look nicer than REQUI-1 / CANDI-1 derived from the collection
|
|
// name). Verifying here also catches any regression in the prefix
|
|
// pipeline from template → CollectionCreate.
|
|
for slug, want := range map[string]string{
|
|
"requisitions": "REQ",
|
|
"candidates": "CAND",
|
|
"interview-loops": "LOOP",
|
|
"feedback": "FB",
|
|
} {
|
|
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
|
if err != nil || coll == nil {
|
|
continue
|
|
}
|
|
if coll.Prefix != want {
|
|
t.Errorf("collection %q prefix = %q, want %q", slug, coll.Prefix, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateInterviewing verifies end-to-end that the
|
|
// interviewing template creates its collections and seeds the starter pack.
|
|
func TestSeedCollectionsFromTemplateInterviewing(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Interviewing")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "interviewing"); err != nil {
|
|
t.Fatalf("seed interviewing template: %v", err)
|
|
}
|
|
|
|
for _, slug := range []string{"applications", "interviews", "companies", "contacts", "docs", "conventions", "playbooks"} {
|
|
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
|
if err != nil || coll == nil {
|
|
t.Errorf("interviewing workspace missing collection %q (err=%v)", slug, err)
|
|
}
|
|
}
|
|
|
|
convs, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if len(convs) == 0 {
|
|
t.Error("expected interviewing starter conventions to be seeded, got 0")
|
|
}
|
|
plays, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "playbooks"})
|
|
if len(plays) == 0 {
|
|
t.Error("expected interviewing starter playbooks to be seeded, got 0")
|
|
}
|
|
|
|
apps, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "applications"})
|
|
if len(apps) == 0 {
|
|
t.Error("expected interviewing seed Application, got 0")
|
|
}
|
|
cos, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "companies"})
|
|
if len(cos) == 0 {
|
|
t.Error("expected interviewing seed Company, got 0")
|
|
}
|
|
|
|
// Explicit prefixes
|
|
for slug, want := range map[string]string{
|
|
"applications": "APP",
|
|
"interviews": "INT",
|
|
"companies": "CO",
|
|
"contacts": "CON",
|
|
} {
|
|
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
|
if err != nil || coll == nil {
|
|
continue
|
|
}
|
|
if coll.Prefix != want {
|
|
t.Errorf("collection %q prefix = %q, want %q", slug, coll.Prefix, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateRecoversPartialInit verifies that a retry
|
|
// after a partial seed (some items missing) fills in the missing items
|
|
// rather than treating the workspace as already-seeded. This guards the
|
|
// idempotency-by-title design — the freshlyCreated-only design trapped
|
|
// partially-initialized workspaces because the second pass saw existing
|
|
// collections and skipped all items.
|
|
func TestSeedCollectionsFromTemplateRecoversPartialInit(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Recover")
|
|
|
|
// Simulate a partial init: seed the collections manually without any items.
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, ""); err != nil {
|
|
t.Fatalf("initial (no-template) seed error: %v", err)
|
|
}
|
|
// Collections exist but conventions collection has 0 items.
|
|
convColl, _ := s.GetCollectionBySlug(ws.ID, "conventions")
|
|
before, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
_ = convColl
|
|
if len(before) != 0 {
|
|
t.Fatalf("expected 0 conventions after empty-template seed, got %d", len(before))
|
|
}
|
|
|
|
// Retry with an explicit template — should fill in the starter pack
|
|
// even though the collections already exist.
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("retry seed error: %v", err)
|
|
}
|
|
after, _ := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if len(after) == 0 {
|
|
t.Errorf("expected starter conventions to be seeded on retry, got 0")
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateStartupRefSequence verifies the central
|
|
// invariant of the onboarding seed design: a fresh `startup` workspace
|
|
// produces refs IDEA-1, PLAN-2, TASK-3, DOC-4 — in that order — for the
|
|
// four onboarding seed items. The post-signup hint copy points users at
|
|
// IDEA-1 specifically, so the ref sequence MUST be stable: if conventions
|
|
// or playbooks ever leak in front of the SeedItems loop, IDEA-1 won't be
|
|
// the first item and the hint silently misfires.
|
|
func TestSeedCollectionsFromTemplateStartupRefSequence(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Startup Refs")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("seed startup template: %v", err)
|
|
}
|
|
|
|
// Look up each onboarding seed by collection + title and assert its
|
|
// item_number. Title-based lookup is robust against accidental
|
|
// re-ordering inside collection lists.
|
|
type expected struct {
|
|
Slug, Title string
|
|
WantItemNum int
|
|
}
|
|
want := []expected{
|
|
{"ideas", "Welcome — let's get this place set up", 1},
|
|
{"plans", "A plan I haven't written yet", 2},
|
|
{"tasks", "A task I haven't named yet", 3},
|
|
{"docs", "A doc I haven't written yet", 4},
|
|
}
|
|
|
|
for _, w := range want {
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: w.Slug})
|
|
if err != nil {
|
|
t.Fatalf("list %s: %v", w.Slug, err)
|
|
}
|
|
var found *models.Item
|
|
for i := range items {
|
|
if items[i].Title == w.Title {
|
|
found = &items[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Errorf("expected seed item %q in %q collection, not found", w.Title, w.Slug)
|
|
continue
|
|
}
|
|
if found.ItemNumber == nil {
|
|
t.Errorf("seed item %q in %q: ItemNumber is nil", w.Title, w.Slug)
|
|
continue
|
|
}
|
|
if *found.ItemNumber != w.WantItemNum {
|
|
t.Errorf("seed item %q in %q: ItemNumber = %d, want %d (drift in seeding order means the post-signup hint will point at the wrong ref)", w.Title, w.Slug, *found.ItemNumber, w.WantItemNum)
|
|
}
|
|
if found.Content == "" {
|
|
t.Errorf("seed item %q in %q: empty Content (body lost between template and store)", w.Title, w.Slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateScrumRefSequence is the scrum-template
|
|
// sibling of TestSeedCollectionsFromTemplateStartupRefSequence. Locks
|
|
// down the BACK-1 / SPRINT-2 / BUG-3 / DOC-4 sequence for fresh scrum
|
|
// workspaces — same reasoning as the startup version: drift here
|
|
// silently misfires the post-signup hint, which will name BACK-1.
|
|
func TestSeedCollectionsFromTemplateScrumRefSequence(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Scrum Refs")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "scrum"); err != nil {
|
|
t.Fatalf("seed scrum template: %v", err)
|
|
}
|
|
|
|
type expected struct {
|
|
Slug, Title string
|
|
WantItemNum int
|
|
WantPrefix string
|
|
}
|
|
want := []expected{
|
|
{"backlog", "Welcome — let's start tracking what we're going to build", 1, "BACK"},
|
|
{"sprints", "A sprint I haven't planned yet", 2, "SPRINT"},
|
|
{"bugs", "A bug I haven't filed yet", 3, "BUG"},
|
|
{"docs", "A doc I haven't written yet", 4, "DOC"},
|
|
}
|
|
|
|
for _, w := range want {
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: w.Slug})
|
|
if err != nil {
|
|
t.Fatalf("list %s: %v", w.Slug, err)
|
|
}
|
|
var found *models.Item
|
|
for i := range items {
|
|
if items[i].Title == w.Title {
|
|
found = &items[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Errorf("expected scrum seed %q in %q, not found", w.Title, w.Slug)
|
|
continue
|
|
}
|
|
if found.ItemNumber == nil || *found.ItemNumber != w.WantItemNum {
|
|
t.Errorf("scrum seed %q in %q: ItemNumber = %v, want %d (seeding order regressed)", w.Title, w.Slug, found.ItemNumber, w.WantItemNum)
|
|
}
|
|
if found.CollectionPrefix != w.WantPrefix {
|
|
t.Errorf("scrum seed %q in %q: CollectionPrefix = %q, want %q (templates.go's explicit Prefix may have drifted; see PLAN-1146 prefix precondition)", w.Title, w.Slug, found.CollectionPrefix, w.WantPrefix)
|
|
}
|
|
if found.Content == "" {
|
|
t.Errorf("scrum seed %q in %q: empty Content", w.Title, w.Slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateProductRefSequence is the
|
|
// product-template sibling. Locks FEAT-1 / FB-2 / ROAD-3 / DOC-4.
|
|
func TestSeedCollectionsFromTemplateProductRefSequence(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Product Refs")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "product"); err != nil {
|
|
t.Fatalf("seed product template: %v", err)
|
|
}
|
|
|
|
type expected struct {
|
|
Slug, Title string
|
|
WantItemNum int
|
|
WantPrefix string
|
|
}
|
|
want := []expected{
|
|
{"features", "Welcome — let's start shaping what we're going to ship", 1, "FEAT"},
|
|
{"feedback", "A piece of feedback I haven't captured yet", 2, "FB"},
|
|
{"roadmap-items", "A roadmap commitment I haven't placed yet", 3, "ROAD"},
|
|
{"docs", "A doc I haven't written yet", 4, "DOC"},
|
|
}
|
|
|
|
for _, w := range want {
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: w.Slug})
|
|
if err != nil {
|
|
t.Fatalf("list %s: %v", w.Slug, err)
|
|
}
|
|
var found *models.Item
|
|
for i := range items {
|
|
if items[i].Title == w.Title {
|
|
found = &items[i]
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Errorf("expected product seed %q in %q, not found", w.Title, w.Slug)
|
|
continue
|
|
}
|
|
if found.ItemNumber == nil || *found.ItemNumber != w.WantItemNum {
|
|
t.Errorf("product seed %q in %q: ItemNumber = %v, want %d (seeding order regressed)", w.Title, w.Slug, found.ItemNumber, w.WantItemNum)
|
|
}
|
|
if found.CollectionPrefix != w.WantPrefix {
|
|
t.Errorf("product seed %q in %q: CollectionPrefix = %q, want %q (templates.go's explicit Prefix may have drifted; see PLAN-1146 prefix precondition)", w.Title, w.Slug, found.CollectionPrefix, w.WantPrefix)
|
|
}
|
|
if found.Content == "" {
|
|
t.Errorf("product seed %q in %q: empty Content", w.Title, w.Slug)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSeedCollectionsFromTemplateIdempotentWithSeedItems verifies that re-running
|
|
// the seed function across a pre-existing workspace does NOT duplicate seed items.
|
|
// This invariant is what lets the server's startup auto-upgrade safely iterate
|
|
// every workspace without creating duplicate convention/playbook items each boot.
|
|
func TestSeedCollectionsFromTemplateIdempotentWithSeedItems(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Idempotent")
|
|
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("first seed error: %v", err)
|
|
}
|
|
|
|
before, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if err != nil {
|
|
t.Fatalf("list conventions: %v", err)
|
|
}
|
|
initialCount := len(before)
|
|
if initialCount == 0 {
|
|
t.Fatalf("expected starter conventions after first seed")
|
|
}
|
|
|
|
// Re-seed — simulates the server's startup auto-upgrade running again.
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("second seed error: %v", err)
|
|
}
|
|
|
|
after, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "conventions"})
|
|
if err != nil {
|
|
t.Fatalf("list conventions after re-seed: %v", err)
|
|
}
|
|
if len(after) != initialCount {
|
|
t.Errorf("re-seed duplicated conventions: before=%d, after=%d", initialCount, len(after))
|
|
}
|
|
}
|
|
|
|
// TestOnboardingFlow_FullWalkthrough_Startup is the end-to-end gate test
|
|
// for the IDEA-1 onboarding feature (PLAN-1131). It walks the entire
|
|
// arc of a fresh-account-to-self-sufficient-user flow at the store
|
|
// level, exercising the API surface that real workspace creation and
|
|
// real agent activity ultimately call through.
|
|
//
|
|
// The "agent" steps are stubbed: instead of running an actual agent
|
|
// session, the test makes the same CRUD calls a well-behaved agent
|
|
// would make after reading IDEA-1's body. That's the right scope for
|
|
// a state-machine test — IDEA-1's body content (the agent's
|
|
// instructions) is independently locked down by the resource-pipeline
|
|
// test (TASK-1136), so this layer doesn't need to simulate the
|
|
// agent's reasoning.
|
|
//
|
|
// The phases mirror the user's experience:
|
|
//
|
|
// Phase 1: Fresh workspace creation (seeder runs).
|
|
// - The four onboarding seeds land at IDEA-1 / PLAN-2 / TASK-3 / DOC-4.
|
|
// - Conventions + playbooks land too (seeded after the four user-facing items).
|
|
// - IDEA-1 starts in status=new (the gate the post-signup hint relies on).
|
|
//
|
|
// Phase 2: User opens an agent session, says "use pad to get IDEA-1".
|
|
// - Agent reads IDEA-1, flips status new → exploring (signaling engagement).
|
|
// - User describes their real project; agent captures it as a plan,
|
|
// three tasks under it, and one idea — all in the user's actual
|
|
// collections, not toy data.
|
|
// - Agent closes IDEA-1 by flipping status exploring → implemented.
|
|
//
|
|
// Phase 3: User re-runs the trigger phrase later (idempotency test).
|
|
// - Re-seeding the workspace must be a no-op:
|
|
// - User's items remain untouched.
|
|
// - IDEA-1 status stays at `implemented` (NOT reset to `new`,
|
|
// which would silently re-show the dashboard banner).
|
|
// - No duplicate seed items.
|
|
// - Conventions + playbooks unchanged.
|
|
//
|
|
// If this test ever fails, walk back through PLAN-1131's success
|
|
// criteria (dashboard hint correctness, seed ordering, idempotency)
|
|
// before "fixing" the test.
|
|
func TestOnboardingFlow_FullWalkthrough_Startup(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Onboarding Walkthrough")
|
|
|
|
// ── Phase 1: fresh-workspace seed ──────────────────────────────
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("Phase 1 seed: %v", err)
|
|
}
|
|
|
|
type seedExpect struct {
|
|
Slug, Title, Status string
|
|
ItemNum int
|
|
}
|
|
wantSeeds := []seedExpect{
|
|
{"ideas", "Welcome — let's get this place set up", "new", 1},
|
|
{"plans", "A plan I haven't written yet", "planned", 2},
|
|
{"tasks", "A task I haven't named yet", "open", 3},
|
|
{"docs", "A doc I haven't written yet", "draft", 4},
|
|
}
|
|
for _, want := range wantSeeds {
|
|
got := findItemByTitle(t, s, ws.ID, want.Slug, want.Title)
|
|
if got == nil {
|
|
t.Fatalf("Phase 1: expected seed %q in %q, not found", want.Title, want.Slug)
|
|
}
|
|
if got.ItemNumber == nil || *got.ItemNumber != want.ItemNum {
|
|
t.Errorf("Phase 1: %q item_number = %v, want %d (seeding order regressed)", want.Title, got.ItemNumber, want.ItemNum)
|
|
}
|
|
if status := extractStatus(got.Fields); status != want.Status {
|
|
t.Errorf("Phase 1: %q status = %q, want %q", want.Title, status, want.Status)
|
|
}
|
|
}
|
|
convCountPhase1 := countItemsInCollection(t, s, ws.ID, "conventions")
|
|
playCountPhase1 := countItemsInCollection(t, s, ws.ID, "playbooks")
|
|
if convCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter conventions did not seed")
|
|
}
|
|
if playCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter playbooks did not seed")
|
|
}
|
|
|
|
// ── Phase 2: agent walks user through populating real items ────
|
|
idea1 := findItemByTitle(t, s, ws.ID, "ideas", "Welcome — let's get this place set up")
|
|
if idea1 == nil {
|
|
t.Fatal("Phase 2: IDEA-1 missing — Phase 1 should have caught this")
|
|
}
|
|
|
|
// Agent: status new → exploring (signaling "I've read this and started").
|
|
setItemStatus(t, s, idea1.ID, "exploring")
|
|
|
|
// User describes a real project; agent captures one plan + three
|
|
// tasks + one idea. We use the existing user-facing collections so
|
|
// the test exercises the same API surface real activity hits.
|
|
plansColl, _ := s.GetCollectionBySlug(ws.ID, "plans")
|
|
tasksColl, _ := s.GetCollectionBySlug(ws.ID, "tasks")
|
|
ideasColl, _ := s.GetCollectionBySlug(ws.ID, "ideas")
|
|
|
|
userPlan, err := s.CreateItem(ws.ID, plansColl.ID, models.ItemCreate{
|
|
Title: "Ship the v1 release",
|
|
Content: "Lock down core features, write release notes, ship.",
|
|
Fields: `{"status":"active"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create user plan: %v", err)
|
|
}
|
|
|
|
for _, task := range []struct{ Title, Status string }{
|
|
{"Cut release branch", "in-progress"},
|
|
{"Write changelog from PLAN-2 work", "open"},
|
|
{"Tag and publish", "open"},
|
|
} {
|
|
_, err := s.CreateItem(ws.ID, tasksColl.ID, models.ItemCreate{
|
|
Title: task.Title,
|
|
Fields: `{"status":"` + task.Status + `","priority":"medium"}`,
|
|
ParentID: &userPlan.ID,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create task %q: %v", task.Title, err)
|
|
}
|
|
}
|
|
|
|
if _, err := s.CreateItem(ws.ID, ideasColl.ID, models.ItemCreate{
|
|
Title: "Could we add CI auto-release on tag push?",
|
|
Fields: `{"status":"new"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
}); err != nil {
|
|
t.Fatalf("Phase 2: create user idea: %v", err)
|
|
}
|
|
|
|
// Agent: status exploring → implemented (closes IDEA-1, hides banner).
|
|
setItemStatus(t, s, idea1.ID, "implemented")
|
|
|
|
// Phase 2 sanity: dashboard-shaped counts.
|
|
if got := countItemsInCollection(t, s, ws.ID, "plans"); got != 2 {
|
|
t.Errorf("Phase 2: plans count = %d, want 2 (PLAN-2 seed + user's plan)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "tasks"); got != 4 {
|
|
t.Errorf("Phase 2: tasks count = %d, want 4 (TASK-3 seed + 3 user tasks)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "ideas"); got != 2 {
|
|
t.Errorf("Phase 2: ideas count = %d, want 2 (IDEA-1 seed + user's idea)", got)
|
|
}
|
|
idea1Refresh := findItemByTitle(t, s, ws.ID, "ideas", "Welcome — let's get this place set up")
|
|
if idea1Refresh == nil || extractStatus(idea1Refresh.Fields) != "implemented" {
|
|
t.Errorf("Phase 2: IDEA-1 status not flipped to `implemented` (got %q)", extractStatus(safeFields(idea1Refresh)))
|
|
}
|
|
|
|
// ── Phase 3: idempotency on re-trigger ─────────────────────────
|
|
// The user re-runs `use pad to get IDEA-1` later; the workspace
|
|
// bootstrap path may run again (e.g. server-startup auto-upgrade).
|
|
// Re-seeding MUST NOT:
|
|
// - Reset IDEA-1 from `implemented` back to `new` (that would
|
|
// re-show the dashboard banner and confuse the user).
|
|
// - Duplicate the four onboarding seeds.
|
|
// - Touch the user's plan/tasks/idea.
|
|
// - Re-add conventions/playbooks (already covered by
|
|
// TestSeedCollectionsFromTemplateIdempotentWithSeedItems, but
|
|
// re-checked here so the full-walkthrough story stands alone).
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
|
t.Fatalf("Phase 3 re-seed: %v", err)
|
|
}
|
|
|
|
// IDEA-1 status preserved.
|
|
idea1Final := findItemByTitle(t, s, ws.ID, "ideas", "Welcome — let's get this place set up")
|
|
if idea1Final == nil {
|
|
t.Fatal("Phase 3: IDEA-1 disappeared on re-seed")
|
|
}
|
|
if status := extractStatus(idea1Final.Fields); status != "implemented" {
|
|
t.Errorf("Phase 3: IDEA-1 status = %q after re-seed, want `implemented` (re-seed must NOT reset user-engaged items)", status)
|
|
}
|
|
|
|
// User items preserved.
|
|
if got := countItemsInCollection(t, s, ws.ID, "plans"); got != 2 {
|
|
t.Errorf("Phase 3: plans count = %d after re-seed, want 2 (re-seed should not duplicate or remove)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "tasks"); got != 4 {
|
|
t.Errorf("Phase 3: tasks count = %d after re-seed, want 4", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "ideas"); got != 2 {
|
|
t.Errorf("Phase 3: ideas count = %d after re-seed, want 2", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "docs"); got != 1 {
|
|
t.Errorf("Phase 3: docs count = %d after re-seed, want 1 (DOC-4 seed only)", got)
|
|
}
|
|
|
|
// Conventions + playbooks counts unchanged.
|
|
if got := countItemsInCollection(t, s, ws.ID, "conventions"); got != convCountPhase1 {
|
|
t.Errorf("Phase 3: conventions count = %d, want %d (re-seed duplicated)", got, convCountPhase1)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "playbooks"); got != playCountPhase1 {
|
|
t.Errorf("Phase 3: playbooks count = %d, want %d (re-seed duplicated)", got, playCountPhase1)
|
|
}
|
|
}
|
|
|
|
// TestOnboardingFlow_FullWalkthrough_Scrum is the scrum-template sibling
|
|
// of TestOnboardingFlow_FullWalkthrough_Startup. Same three-phase shape
|
|
// — fresh seed → user activity → idempotent re-trigger — but the verbs
|
|
// are scrum's: the user populates a sprint, fills the backlog with
|
|
// real items, and reports a bug. The seed primary (BACK-1) flips out
|
|
// of `new` once the agent engages.
|
|
//
|
|
// Failure of any phase signals a PLAN-1146 success-criteria
|
|
// regression for the scrum template; walk back through DOC-1152
|
|
// before "fixing" the test.
|
|
func TestOnboardingFlow_FullWalkthrough_Scrum(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Scrum Walkthrough")
|
|
|
|
// ── Phase 1: fresh-workspace seed ──────────────────────────────
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "scrum"); err != nil {
|
|
t.Fatalf("Phase 1 seed: %v", err)
|
|
}
|
|
|
|
type seedExpect struct {
|
|
Slug, Title, Status string
|
|
ItemNum int
|
|
}
|
|
wantSeeds := []seedExpect{
|
|
{"backlog", "Welcome — let's start tracking what we're going to build", "new", 1},
|
|
{"sprints", "A sprint I haven't planned yet", "planning", 2},
|
|
{"bugs", "A bug I haven't filed yet", "new", 3},
|
|
{"docs", "A doc I haven't written yet", "draft", 4},
|
|
}
|
|
for _, want := range wantSeeds {
|
|
got := findItemByTitle(t, s, ws.ID, want.Slug, want.Title)
|
|
if got == nil {
|
|
t.Fatalf("Phase 1: expected seed %q in %q, not found", want.Title, want.Slug)
|
|
}
|
|
if got.ItemNumber == nil || *got.ItemNumber != want.ItemNum {
|
|
t.Errorf("Phase 1: %q item_number = %v, want %d", want.Title, got.ItemNumber, want.ItemNum)
|
|
}
|
|
if status := extractStatus(got.Fields); status != want.Status {
|
|
t.Errorf("Phase 1: %q status = %q, want %q", want.Title, status, want.Status)
|
|
}
|
|
}
|
|
convCountPhase1 := countItemsInCollection(t, s, ws.ID, "conventions")
|
|
playCountPhase1 := countItemsInCollection(t, s, ws.ID, "playbooks")
|
|
if convCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter conventions did not seed")
|
|
}
|
|
if playCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter playbooks did not seed")
|
|
}
|
|
|
|
// ── Phase 2: agent walks user through populating real items ────
|
|
back1 := findItemByTitle(t, s, ws.ID, "backlog", "Welcome — let's start tracking what we're going to build")
|
|
if back1 == nil {
|
|
t.Fatal("Phase 2: BACK-1 missing")
|
|
}
|
|
|
|
// Agent: BACK-1 status new → ready (signaling engagement; the user's
|
|
// own backlog items get tracked separately).
|
|
setItemStatus(t, s, back1.ID, "ready")
|
|
|
|
// User describes their actual scrum work: a real sprint, a few
|
|
// backlog items rolled into it, one bug.
|
|
backlogColl, _ := s.GetCollectionBySlug(ws.ID, "backlog")
|
|
sprintsColl, _ := s.GetCollectionBySlug(ws.ID, "sprints")
|
|
bugsColl, _ := s.GetCollectionBySlug(ws.ID, "bugs")
|
|
|
|
userSprint, err := s.CreateItem(ws.ID, sprintsColl.ID, models.ItemCreate{
|
|
Title: "Sprint 24 — auth hardening",
|
|
Content: "Two-week sprint focused on session + token rotation work.",
|
|
Fields: `{"status":"active","start_date":"2026-05-04","end_date":"2026-05-18"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create user sprint: %v", err)
|
|
}
|
|
|
|
for _, item := range []struct{ Title, Status string }{
|
|
{"Refresh-token rotation on session resume", "in_sprint"},
|
|
{"Audit log for failed-login attempts", "ready"},
|
|
{"Rate-limit /auth/login by IP", "ready"},
|
|
} {
|
|
_, err := s.CreateItem(ws.ID, backlogColl.ID, models.ItemCreate{
|
|
Title: item.Title,
|
|
Fields: `{"status":"` + item.Status + `","priority":"high"}`,
|
|
ParentID: &userSprint.ID,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create backlog %q: %v", item.Title, err)
|
|
}
|
|
}
|
|
|
|
if _, err := s.CreateItem(ws.ID, bugsColl.ID, models.ItemCreate{
|
|
Title: "Login screen flashes briefly before redirect when already signed in",
|
|
Fields: `{"status":"triaged","severity":"low"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
}); err != nil {
|
|
t.Fatalf("Phase 2: create user bug: %v", err)
|
|
}
|
|
|
|
// Agent: BACK-1 status ready → done (closes the seed; banner hides).
|
|
setItemStatus(t, s, back1.ID, "done")
|
|
|
|
// Phase 2 sanity counts.
|
|
if got := countItemsInCollection(t, s, ws.ID, "backlog"); got != 4 {
|
|
t.Errorf("Phase 2: backlog count = %d, want 4 (BACK-1 seed + 3 user items)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "sprints"); got != 2 {
|
|
t.Errorf("Phase 2: sprints count = %d, want 2 (SPRINT-2 seed + user's sprint)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "bugs"); got != 2 {
|
|
t.Errorf("Phase 2: bugs count = %d, want 2 (BUG-3 seed + user's bug)", got)
|
|
}
|
|
back1Refresh := findItemByTitle(t, s, ws.ID, "backlog", "Welcome — let's start tracking what we're going to build")
|
|
if back1Refresh == nil || extractStatus(back1Refresh.Fields) != "done" {
|
|
t.Errorf("Phase 2: BACK-1 not flipped to `done` (got %q)", extractStatus(safeFields(back1Refresh)))
|
|
}
|
|
|
|
// ── Phase 3: idempotency on re-trigger ─────────────────────────
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "scrum"); err != nil {
|
|
t.Fatalf("Phase 3 re-seed: %v", err)
|
|
}
|
|
back1Final := findItemByTitle(t, s, ws.ID, "backlog", "Welcome — let's start tracking what we're going to build")
|
|
if back1Final == nil {
|
|
t.Fatal("Phase 3: BACK-1 disappeared on re-seed")
|
|
}
|
|
if status := extractStatus(back1Final.Fields); status != "done" {
|
|
t.Errorf("Phase 3: BACK-1 status = %q after re-seed, want `done` (re-seed must NOT reset user-engaged items)", status)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "backlog"); got != 4 {
|
|
t.Errorf("Phase 3: backlog count = %d, want 4", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "sprints"); got != 2 {
|
|
t.Errorf("Phase 3: sprints count = %d, want 2", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "bugs"); got != 2 {
|
|
t.Errorf("Phase 3: bugs count = %d, want 2", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "conventions"); got != convCountPhase1 {
|
|
t.Errorf("Phase 3: conventions count = %d, want %d", got, convCountPhase1)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "playbooks"); got != playCountPhase1 {
|
|
t.Errorf("Phase 3: playbooks count = %d, want %d", got, playCountPhase1)
|
|
}
|
|
}
|
|
|
|
// TestOnboardingFlow_FullWalkthrough_Product is the product-template
|
|
// sibling. Verbs are product's: the user defines a feature, captures
|
|
// real user feedback, places a roadmap commitment. FEAT-1 flips from
|
|
// `proposed` to `shipped` (terminal status for Features) at the end.
|
|
func TestOnboardingFlow_FullWalkthrough_Product(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Product Walkthrough")
|
|
|
|
// ── Phase 1: fresh-workspace seed ──────────────────────────────
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "product"); err != nil {
|
|
t.Fatalf("Phase 1 seed: %v", err)
|
|
}
|
|
|
|
type seedExpect struct {
|
|
Slug, Title, Status string
|
|
ItemNum int
|
|
}
|
|
wantSeeds := []seedExpect{
|
|
{"features", "Welcome — let's start shaping what we're going to ship", "proposed", 1},
|
|
{"feedback", "A piece of feedback I haven't captured yet", "new", 2},
|
|
{"roadmap-items", "A roadmap commitment I haven't placed yet", "planned", 3},
|
|
{"docs", "A doc I haven't written yet", "draft", 4},
|
|
}
|
|
for _, want := range wantSeeds {
|
|
got := findItemByTitle(t, s, ws.ID, want.Slug, want.Title)
|
|
if got == nil {
|
|
t.Fatalf("Phase 1: expected seed %q in %q, not found", want.Title, want.Slug)
|
|
}
|
|
if got.ItemNumber == nil || *got.ItemNumber != want.ItemNum {
|
|
t.Errorf("Phase 1: %q item_number = %v, want %d", want.Title, got.ItemNumber, want.ItemNum)
|
|
}
|
|
if status := extractStatus(got.Fields); status != want.Status {
|
|
t.Errorf("Phase 1: %q status = %q, want %q", want.Title, status, want.Status)
|
|
}
|
|
}
|
|
convCountPhase1 := countItemsInCollection(t, s, ws.ID, "conventions")
|
|
playCountPhase1 := countItemsInCollection(t, s, ws.ID, "playbooks")
|
|
if convCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter conventions did not seed")
|
|
}
|
|
if playCountPhase1 == 0 {
|
|
t.Fatal("Phase 1: starter playbooks did not seed")
|
|
}
|
|
|
|
// ── Phase 2: agent walks user through populating real items ────
|
|
feat1 := findItemByTitle(t, s, ws.ID, "features", "Welcome — let's start shaping what we're going to ship")
|
|
if feat1 == nil {
|
|
t.Fatal("Phase 2: FEAT-1 missing")
|
|
}
|
|
|
|
// Agent: FEAT-1 status proposed → researching.
|
|
setItemStatus(t, s, feat1.ID, "researching")
|
|
|
|
featuresColl, _ := s.GetCollectionBySlug(ws.ID, "features")
|
|
feedbackColl, _ := s.GetCollectionBySlug(ws.ID, "feedback")
|
|
roadmapColl, _ := s.GetCollectionBySlug(ws.ID, "roadmap-items")
|
|
|
|
userRoadmap, err := s.CreateItem(ws.ID, roadmapColl.ID, models.ItemCreate{
|
|
Title: "Self-serve onboarding by Q3",
|
|
Content: "Cut time-to-first-value for new sign-ups in half.",
|
|
Fields: `{"status":"in_progress","quarter":"2026-Q3"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create user roadmap: %v", err)
|
|
}
|
|
|
|
for _, item := range []struct{ Title, Status string }{
|
|
{"Welcome modal with first-task picker", "building"},
|
|
{"Empty-state CTA on dashboard", "planned"},
|
|
{"Magic link signup (no password)", "researching"},
|
|
} {
|
|
_, err := s.CreateItem(ws.ID, featuresColl.ID, models.ItemCreate{
|
|
Title: item.Title,
|
|
Fields: `{"status":"` + item.Status + `","priority":"high"}`,
|
|
ParentID: &userRoadmap.ID,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("Phase 2: create feature %q: %v", item.Title, err)
|
|
}
|
|
}
|
|
|
|
if _, err := s.CreateItem(ws.ID, feedbackColl.ID, models.ItemCreate{
|
|
Title: `"I bounced off because I didn't know what to do first"`,
|
|
Content: "Sales call quote, mid-market lead. Echoed by 3 other prospects this month.",
|
|
Fields: `{"status":"reviewed","source":"sales-call","impact":"high"}`,
|
|
CreatedBy: "agent",
|
|
Source: "cli",
|
|
}); err != nil {
|
|
t.Fatalf("Phase 2: create user feedback: %v", err)
|
|
}
|
|
|
|
// Agent: FEAT-1 status researching → shipped (closes the seed).
|
|
setItemStatus(t, s, feat1.ID, "shipped")
|
|
|
|
// Phase 2 sanity counts.
|
|
if got := countItemsInCollection(t, s, ws.ID, "features"); got != 4 {
|
|
t.Errorf("Phase 2: features count = %d, want 4 (FEAT-1 seed + 3 user features)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "feedback"); got != 2 {
|
|
t.Errorf("Phase 2: feedback count = %d, want 2 (FB-2 seed + user's feedback)", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "roadmap-items"); got != 2 {
|
|
t.Errorf("Phase 2: roadmap count = %d, want 2 (ROAD-3 seed + user's roadmap item)", got)
|
|
}
|
|
feat1Refresh := findItemByTitle(t, s, ws.ID, "features", "Welcome — let's start shaping what we're going to ship")
|
|
if feat1Refresh == nil || extractStatus(feat1Refresh.Fields) != "shipped" {
|
|
t.Errorf("Phase 2: FEAT-1 not flipped to `shipped` (got %q)", extractStatus(safeFields(feat1Refresh)))
|
|
}
|
|
|
|
// ── Phase 3: idempotency on re-trigger ─────────────────────────
|
|
if err := s.SeedCollectionsFromTemplate(ws.ID, "product"); err != nil {
|
|
t.Fatalf("Phase 3 re-seed: %v", err)
|
|
}
|
|
feat1Final := findItemByTitle(t, s, ws.ID, "features", "Welcome — let's start shaping what we're going to ship")
|
|
if feat1Final == nil {
|
|
t.Fatal("Phase 3: FEAT-1 disappeared on re-seed")
|
|
}
|
|
if status := extractStatus(feat1Final.Fields); status != "shipped" {
|
|
t.Errorf("Phase 3: FEAT-1 status = %q after re-seed, want `shipped`", status)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "features"); got != 4 {
|
|
t.Errorf("Phase 3: features count = %d, want 4", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "feedback"); got != 2 {
|
|
t.Errorf("Phase 3: feedback count = %d, want 2", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "roadmap-items"); got != 2 {
|
|
t.Errorf("Phase 3: roadmap count = %d, want 2", got)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "conventions"); got != convCountPhase1 {
|
|
t.Errorf("Phase 3: conventions count = %d, want %d", got, convCountPhase1)
|
|
}
|
|
if got := countItemsInCollection(t, s, ws.ID, "playbooks"); got != playCountPhase1 {
|
|
t.Errorf("Phase 3: playbooks count = %d, want %d", got, playCountPhase1)
|
|
}
|
|
}
|
|
|
|
// findItemByTitle is a helper for the onboarding-walkthrough test:
|
|
// look up an item in a collection by exact title. Used because the
|
|
// store's ResolveItem expects either UUID, ref, or slug, not a title.
|
|
func findItemByTitle(t *testing.T, s *Store, workspaceID, collSlug, title string) *models.Item {
|
|
t.Helper()
|
|
items, err := s.ListItems(workspaceID, models.ItemListParams{CollectionSlug: collSlug})
|
|
if err != nil {
|
|
t.Fatalf("findItemByTitle: list %s: %v", collSlug, err)
|
|
}
|
|
for i := range items {
|
|
if items[i].Title == title {
|
|
return &items[i]
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// extractStatus pulls the `status` field out of an item's fields JSON.
|
|
// Returns "" for empty / malformed payloads — convenient for "did the
|
|
// status field land?" assertions without a hard parse failure.
|
|
func extractStatus(fieldsJSON string) string {
|
|
if fieldsJSON == "" || fieldsJSON == "{}" {
|
|
return ""
|
|
}
|
|
var fields map[string]any
|
|
if err := json.Unmarshal([]byte(fieldsJSON), &fields); err != nil {
|
|
return ""
|
|
}
|
|
if status, ok := fields["status"].(string); ok {
|
|
return status
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// safeFields returns the item's Fields, or an empty string for nil
|
|
// items. Callers use this to compose error messages where the item
|
|
// itself may be nil (e.g. "expected status implemented, got <empty>").
|
|
func safeFields(item *models.Item) string {
|
|
if item == nil {
|
|
return ""
|
|
}
|
|
return item.Fields
|
|
}
|
|
|
|
// setItemStatus updates only the status field of an item, preserving
|
|
// any other fields. Used by the onboarding-walkthrough test to mimic
|
|
// agent-driven status transitions.
|
|
func setItemStatus(t *testing.T, s *Store, itemID, status string) {
|
|
t.Helper()
|
|
current, err := s.GetItem(itemID)
|
|
if err != nil || current == nil {
|
|
t.Fatalf("setItemStatus: get %s: %v", itemID, err)
|
|
}
|
|
var fields map[string]any
|
|
if current.Fields != "" {
|
|
_ = json.Unmarshal([]byte(current.Fields), &fields)
|
|
}
|
|
if fields == nil {
|
|
fields = map[string]any{}
|
|
}
|
|
fields["status"] = status
|
|
updatedJSON, err := json.Marshal(fields)
|
|
if err != nil {
|
|
t.Fatalf("setItemStatus: marshal fields: %v", err)
|
|
}
|
|
updated := string(updatedJSON)
|
|
if _, err := s.UpdateItem(itemID, models.ItemUpdate{
|
|
Fields: &updated,
|
|
LastModifiedBy: "agent",
|
|
Source: "cli",
|
|
}); err != nil {
|
|
t.Fatalf("setItemStatus: update %s: %v", itemID, err)
|
|
}
|
|
}
|
|
|
|
// countItemsInCollection returns the number of (non-deleted) items in
|
|
// a collection. Cheap wrapper over ListItems used by the walkthrough
|
|
// test for "did the count change as expected" assertions.
|
|
func countItemsInCollection(t *testing.T, s *Store, workspaceID, collSlug string) int {
|
|
t.Helper()
|
|
items, err := s.ListItems(workspaceID, models.ItemListParams{CollectionSlug: collSlug})
|
|
if err != nil {
|
|
t.Fatalf("countItemsInCollection: list %s: %v", collSlug, err)
|
|
}
|
|
return len(items)
|
|
}
|
|
|
|
// --- Item Tests ---
|
|
|
|
func TestItemCRUD(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Create
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "My Task",
|
|
Content: "Do something",
|
|
Fields: `{"status":"open"}`,
|
|
Tags: `["important"]`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
if item.Title != "My Task" {
|
|
t.Errorf("expected title 'My Task', got %q", item.Title)
|
|
}
|
|
if item.Slug != "my-task" {
|
|
t.Errorf("expected slug 'my-task', got %q", item.Slug)
|
|
}
|
|
if item.CollectionSlug != col.Slug {
|
|
t.Errorf("expected collection slug %q, got %q", col.Slug, item.CollectionSlug)
|
|
}
|
|
if item.CollectionName != col.Name {
|
|
t.Errorf("expected collection name %q, got %q", col.Name, item.CollectionName)
|
|
}
|
|
|
|
// Get
|
|
got, err := s.GetItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItem error: %v", err)
|
|
}
|
|
if got.Content != "Do something" {
|
|
t.Errorf("expected content 'Do something', got %q", got.Content)
|
|
}
|
|
|
|
// Get by slug
|
|
got, err = s.GetItemBySlug(ws.ID, "my-task")
|
|
if err != nil {
|
|
t.Fatalf("GetItemBySlug error: %v", err)
|
|
}
|
|
if got == nil || got.ID != item.ID {
|
|
t.Error("GetItemBySlug returned wrong item")
|
|
}
|
|
|
|
// Update
|
|
newContent := "Updated content"
|
|
newTitle := "Updated Task"
|
|
updated, err := s.UpdateItem(item.ID, models.ItemUpdate{
|
|
Title: &newTitle,
|
|
Content: &newContent,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem error: %v", err)
|
|
}
|
|
if updated.Content != "Updated content" {
|
|
t.Errorf("expected updated content, got %q", updated.Content)
|
|
}
|
|
if updated.Slug != "updated-task" {
|
|
t.Errorf("expected slug 'updated-task', got %q", updated.Slug)
|
|
}
|
|
|
|
// List
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("expected 1 item, got %d", len(items))
|
|
}
|
|
|
|
// Delete
|
|
err = s.DeleteItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteItem error: %v", err)
|
|
}
|
|
|
|
// Should not appear in list
|
|
items, _ = s.ListItems(ws.ID, models.ItemListParams{})
|
|
if len(items) != 0 {
|
|
t.Error("deleted item still appears in list")
|
|
}
|
|
|
|
// Restore
|
|
restored, err := s.RestoreItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("RestoreItem error: %v", err)
|
|
}
|
|
if restored.Title != "Updated Task" {
|
|
t.Errorf("expected restored title 'Updated Task', got %q", restored.Title)
|
|
}
|
|
|
|
items, _ = s.ListItems(ws.ID, models.ItemListParams{})
|
|
if len(items) != 1 {
|
|
t.Error("restored item not in list")
|
|
}
|
|
}
|
|
|
|
func TestItemCodeContextIsHydratedOnRead(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Link PR",
|
|
Fields: `{"status":"open","github_pr":{"number":7,"url":"https://github.com/PerpetualSoftware/pad/pull/7","title":"Link PR","state":"OPEN","branch":"feat/link-pr","repo":"PerpetualSoftware/pad","updated_at":"2026-04-02T14:00:00Z"}}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
|
|
got, err := s.GetItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItem error: %v", err)
|
|
}
|
|
if got == nil || got.CodeContext == nil {
|
|
t.Fatal("expected code context on item read")
|
|
}
|
|
if got.CodeContext.Branch != "feat/link-pr" {
|
|
t.Fatalf("expected branch feat/link-pr, got %q", got.CodeContext.Branch)
|
|
}
|
|
if got.CodeContext.PullRequest == nil || got.CodeContext.PullRequest.Number != 7 {
|
|
t.Fatalf("expected PR #7, got %#v", got.CodeContext.PullRequest)
|
|
}
|
|
}
|
|
|
|
func TestListItemsIncludesCodeContext(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
_, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Linked Item",
|
|
Fields: `{"status":"open","github_pr":{"number":9,"url":"https://github.com/PerpetualSoftware/pad/pull/9","title":"Linked Item","state":"MERGED","branch":"feat/linked-item","repo":"PerpetualSoftware/pad","updated_at":"2026-04-02T14:10:00Z"}}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Fatalf("expected 1 item, got %d", len(items))
|
|
}
|
|
if items[0].CodeContext == nil {
|
|
t.Fatal("expected list items to include code context")
|
|
}
|
|
if items[0].CodeContext.PullRequest == nil || items[0].CodeContext.PullRequest.State != "MERGED" {
|
|
t.Fatalf("expected merged PR metadata, got %#v", items[0].CodeContext.PullRequest)
|
|
}
|
|
}
|
|
|
|
func TestItemStructuredMetadataIsHydratedOnRead(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Capture reasoning",
|
|
Fields: `{"status":"open","implementation_notes":[{"id":"note-1","summary":"Added typed metadata","details":"Surface it on item responses","created_at":"2026-04-02T16:00:00Z","created_by":"agent"}],"decision_log":[{"id":"decision-1","decision":"Store notes in reserved field keys","rationale":"Avoid a new table for this first cut","created_at":"2026-04-02T16:05:00Z","created_by":"agent"}]}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
|
|
got, err := s.GetItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItem error: %v", err)
|
|
}
|
|
if len(got.ImplementationNotes) != 1 {
|
|
t.Fatalf("expected 1 implementation note, got %#v", got.ImplementationNotes)
|
|
}
|
|
if got.ImplementationNotes[0].Summary != "Added typed metadata" {
|
|
t.Fatalf("expected implementation note summary, got %q", got.ImplementationNotes[0].Summary)
|
|
}
|
|
if len(got.DecisionLog) != 1 {
|
|
t.Fatalf("expected 1 decision log entry, got %#v", got.DecisionLog)
|
|
}
|
|
if got.DecisionLog[0].Decision != "Store notes in reserved field keys" {
|
|
t.Fatalf("expected decision log entry, got %q", got.DecisionLog[0].Decision)
|
|
}
|
|
}
|
|
|
|
func TestConventionMetadataIsHydratedOnRead(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Conventions")
|
|
|
|
item, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Run tests before completion",
|
|
Fields: `{"status":"active","convention":{"category":"quality","trigger":"on-task-complete","surfaces":["all"],"enforcement":"must","commands":["go test ./...","make install"]}}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
|
|
got, err := s.GetItem(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItem error: %v", err)
|
|
}
|
|
if got.Convention == nil {
|
|
t.Fatal("expected convention metadata on item read")
|
|
}
|
|
if got.Convention.Category != "quality" {
|
|
t.Fatalf("expected category quality, got %q", got.Convention.Category)
|
|
}
|
|
if len(got.Convention.Commands) != 2 || got.Convention.Commands[0] != "go test ./..." {
|
|
t.Fatalf("expected commands to be hydrated, got %#v", got.Convention.Commands)
|
|
}
|
|
}
|
|
|
|
func TestItemListByCollection(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
tasks := createTestCollection(t, s, ws.ID, "Tasks")
|
|
ideas := createTestCollection(t, s, ws.ID, "Ideas")
|
|
|
|
createTestItem(t, s, ws.ID, tasks.ID, "Task 1", "")
|
|
createTestItem(t, s, ws.ID, tasks.ID, "Task 2", "")
|
|
createTestItem(t, s, ws.ID, ideas.ID, "Idea 1", "")
|
|
|
|
// Filter by collection
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "tasks"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("expected 2 tasks, got %d", len(items))
|
|
}
|
|
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: "ideas"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("expected 1 idea, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemListByFieldFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Open Task",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Done Task",
|
|
Fields: `{"status":"done"}`,
|
|
})
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Another Open",
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
|
|
// Filter: status=open
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{
|
|
Fields: map[string]string{"status": "open"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems with field filter error: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("expected 2 open items, got %d", len(items))
|
|
}
|
|
|
|
// Filter: status=done
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{
|
|
Fields: map[string]string{"status": "done"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems with field filter error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("expected 1 done item, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemListByTag(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Tagged",
|
|
Tags: `["bug","urgent"]`,
|
|
})
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Not Tagged",
|
|
Tags: `["feature"]`,
|
|
})
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Tag: "bug"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems tag filter error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("expected 1 tagged item, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemListPagination(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
for i := 0; i < 5; i++ {
|
|
s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: fmt.Sprintf("Task %d", i),
|
|
})
|
|
}
|
|
|
|
// First page
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Limit: 2})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("expected 2 items on first page, got %d", len(items))
|
|
}
|
|
|
|
// Second page
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{Limit: 2, Offset: 2})
|
|
if err != nil {
|
|
t.Fatalf("ListItems error: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Errorf("expected 2 items on second page, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemFTSSearch(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
createTestItem(t, s, ws.ID, col.ID, "Fix auth bug", "OAuth2 authentication is broken")
|
|
createTestItem(t, s, ws.ID, col.ID, "Add pagination", "Implement cursor-based pagination")
|
|
|
|
// Search by content
|
|
results, err := s.SearchItems(ws.ID, "authentication")
|
|
if err != nil {
|
|
t.Fatalf("SearchItems error: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("expected 1 search result, got %d", len(results))
|
|
}
|
|
if len(results) > 0 && results[0].Item.Title != "Fix auth bug" {
|
|
t.Errorf("expected 'Fix auth bug', got %q", results[0].Item.Title)
|
|
}
|
|
|
|
// Search by title
|
|
results, err = s.SearchItems(ws.ID, "pagination")
|
|
if err != nil {
|
|
t.Fatalf("SearchItems error: %v", err)
|
|
}
|
|
if len(results) != 1 {
|
|
t.Errorf("expected 1 search result, got %d", len(results))
|
|
}
|
|
}
|
|
|
|
func TestItemFTSSearchViaListItems(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
createTestItem(t, s, ws.ID, col.ID, "Fix auth bug", "OAuth2 authentication is broken")
|
|
createTestItem(t, s, ws.ID, col.ID, "Add pagination", "Implement cursor-based pagination")
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: "authentication"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search error: %v", err)
|
|
}
|
|
if len(items) != 1 {
|
|
t.Errorf("expected 1 item, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemSlugUniqueness(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item1, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "My Task"})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
item2, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{Title: "My Task"})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem error: %v", err)
|
|
}
|
|
|
|
if item1.Slug == item2.Slug {
|
|
t.Error("duplicate slugs should not be allowed")
|
|
}
|
|
if item2.Slug != "my-task-2" {
|
|
t.Errorf("expected slug 'my-task-2', got %q", item2.Slug)
|
|
}
|
|
}
|
|
|
|
func TestCollectionSlugUniqueness(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
col1, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks"})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection error: %v", err)
|
|
}
|
|
col2, err := s.CreateCollection(ws.ID, models.CollectionCreate{Name: "Tasks"})
|
|
if err != nil {
|
|
t.Fatalf("CreateCollection error: %v", err)
|
|
}
|
|
|
|
if col1.Slug == col2.Slug {
|
|
t.Error("duplicate collection slugs should not be allowed")
|
|
}
|
|
if col2.Slug != "tasks-2" {
|
|
t.Errorf("expected slug 'tasks-2', got %q", col2.Slug)
|
|
}
|
|
}
|
|
|
|
// --- Item Link Tests ---
|
|
|
|
func TestItemLinks(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item1 := createTestItem(t, s, ws.ID, col.ID, "Task A", "")
|
|
item2 := createTestItem(t, s, ws.ID, col.ID, "Task B", "")
|
|
|
|
// Create link
|
|
link, err := s.CreateItemLink(ws.ID, models.ItemLinkCreate{
|
|
TargetID: item2.ID,
|
|
LinkType: "blocks",
|
|
}, item1.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreateItemLink error: %v", err)
|
|
}
|
|
if link.SourceID != item1.ID {
|
|
t.Errorf("expected source_id %q, got %q", item1.ID, link.SourceID)
|
|
}
|
|
if link.TargetID != item2.ID {
|
|
t.Errorf("expected target_id %q, got %q", item2.ID, link.TargetID)
|
|
}
|
|
if link.LinkType != "blocks" {
|
|
t.Errorf("expected link_type 'blocks', got %q", link.LinkType)
|
|
}
|
|
if link.SourceTitle != "Task A" {
|
|
t.Errorf("expected source title 'Task A', got %q", link.SourceTitle)
|
|
}
|
|
if link.TargetTitle != "Task B" {
|
|
t.Errorf("expected target title 'Task B', got %q", link.TargetTitle)
|
|
}
|
|
|
|
// Get links for item1
|
|
links, err := s.GetItemLinks(item1.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Errorf("expected 1 link, got %d", len(links))
|
|
}
|
|
|
|
// Get links for item2 (should appear as target)
|
|
links, err = s.GetItemLinks(item2.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks error: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Errorf("expected 1 link for target, got %d", len(links))
|
|
}
|
|
|
|
// Delete link
|
|
err = s.DeleteItemLink(link.ID)
|
|
if err != nil {
|
|
t.Fatalf("DeleteItemLink error: %v", err)
|
|
}
|
|
|
|
links, _ = s.GetItemLinks(item1.ID)
|
|
if len(links) != 0 {
|
|
t.Error("deleted link still appears")
|
|
}
|
|
}
|
|
|
|
// TestItemLinks_HidesSoftDeletedEndpoints exercises BUG-734: when an item that
|
|
// is the source or target of a link gets soft-deleted, GetItemLinks should not
|
|
// surface the link from the surviving endpoint's perspective. Restoring the
|
|
// deleted item should resurrect the link automatically — the row is preserved
|
|
// on disk; only the query layer filters it.
|
|
func TestItemLinks_HidesSoftDeletedEndpoints(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
plan := createTestItem(t, s, ws.ID, col.ID, "Plan", "")
|
|
implementer := createTestItem(t, s, ws.ID, col.ID, "Implementer task", "")
|
|
|
|
// implementer --implements--> plan
|
|
if _, err := s.CreateItemLink(ws.ID, models.ItemLinkCreate{
|
|
TargetID: plan.ID,
|
|
LinkType: "implements",
|
|
}, implementer.ID); err != nil {
|
|
t.Fatalf("CreateItemLink: %v", err)
|
|
}
|
|
|
|
// Sanity: link visible from both endpoints.
|
|
if links, _ := s.GetItemLinks(plan.ID); len(links) != 1 {
|
|
t.Fatalf("expected 1 link from plan side before delete, got %d", len(links))
|
|
}
|
|
if links, _ := s.GetItemLinks(implementer.ID); len(links) != 1 {
|
|
t.Fatalf("expected 1 link from implementer side before delete, got %d", len(links))
|
|
}
|
|
|
|
// Soft-delete the implementer (the BUG-734 scenario: source side gone).
|
|
if err := s.DeleteItem(implementer.ID); err != nil {
|
|
t.Fatalf("DeleteItem: %v", err)
|
|
}
|
|
|
|
// From the plan's perspective, the dangling implementer must not surface.
|
|
links, err := s.GetItemLinks(plan.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks after delete: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Errorf("expected 0 links from plan side after implementer deleted, got %d (orphan leak — BUG-734)", len(links))
|
|
}
|
|
|
|
// Restore the implementer — the link row was never deleted, so the
|
|
// relationship should reappear automatically.
|
|
if _, err := s.RestoreItem(implementer.ID); err != nil {
|
|
t.Fatalf("RestoreItem: %v", err)
|
|
}
|
|
links, err = s.GetItemLinks(plan.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks after restore: %v", err)
|
|
}
|
|
if len(links) != 1 {
|
|
t.Errorf("expected 1 link from plan side after restore, got %d (link should be preserved across soft-delete/restore)", len(links))
|
|
}
|
|
|
|
// Now soft-delete the plan side instead (target side gone) and verify the
|
|
// implementer's view also drops the dangling link.
|
|
if err := s.DeleteItem(plan.ID); err != nil {
|
|
t.Fatalf("DeleteItem plan: %v", err)
|
|
}
|
|
links, err = s.GetItemLinks(implementer.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetItemLinks after target delete: %v", err)
|
|
}
|
|
if len(links) != 0 {
|
|
t.Errorf("expected 0 links from implementer side after plan deleted, got %d (target-side orphan leak)", len(links))
|
|
}
|
|
}
|
|
|
|
// TestGetParentForItem_HidesSoftDeletedParent ensures lineage / breadcrumb
|
|
// queries don't surface a soft-deleted ancestor. See BUG-734.
|
|
func TestGetParentForItem_HidesSoftDeletedParent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
parent := createTestItem(t, s, ws.ID, col.ID, "Parent", "")
|
|
child := createTestItem(t, s, ws.ID, col.ID, "Child", "")
|
|
|
|
if _, err := s.SetParentLink(ws.ID, child.ID, parent.ID, "user"); err != nil {
|
|
t.Fatalf("SetParentLink: %v", err)
|
|
}
|
|
|
|
// Before delete: parent visible.
|
|
if link, err := s.GetParentForItem(child.ID); err != nil {
|
|
t.Fatalf("GetParentForItem: %v", err)
|
|
} else if link == nil {
|
|
t.Fatal("expected parent link before delete, got nil")
|
|
}
|
|
|
|
// Soft-delete parent.
|
|
if err := s.DeleteItem(parent.ID); err != nil {
|
|
t.Fatalf("DeleteItem: %v", err)
|
|
}
|
|
|
|
// After delete: must read as no parent (don't render a deleted breadcrumb).
|
|
link, err := s.GetParentForItem(child.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetParentForItem after delete: %v", err)
|
|
}
|
|
if link != nil {
|
|
t.Errorf("expected nil parent link after soft-delete, got %+v", link)
|
|
}
|
|
|
|
// After restore: parent visible again.
|
|
if _, err := s.RestoreItem(parent.ID); err != nil {
|
|
t.Fatalf("RestoreItem: %v", err)
|
|
}
|
|
if link, err := s.GetParentForItem(child.ID); err != nil {
|
|
t.Fatalf("GetParentForItem after restore: %v", err)
|
|
} else if link == nil {
|
|
t.Error("expected parent link to reappear after restore")
|
|
}
|
|
}
|
|
|
|
// TestGetParentMap_ExcludesSoftDeletedEndpoints covers the dashboard
|
|
// orphan-detection path: a task whose parent has been soft-deleted should
|
|
// NOT appear in GetParentMap, so handlers_dashboard.go correctly flags the
|
|
// task as orphaned. See BUG-734 / Codex review on PR #259.
|
|
func TestGetParentMap_ExcludesSoftDeletedEndpoints(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
parent := createTestItem(t, s, ws.ID, col.ID, "Parent", "")
|
|
child := createTestItem(t, s, ws.ID, col.ID, "Child", "")
|
|
if _, err := s.SetParentLink(ws.ID, child.ID, parent.ID, "user"); err != nil {
|
|
t.Fatalf("SetParentLink: %v", err)
|
|
}
|
|
|
|
// Sanity: child→parent mapping present.
|
|
m, err := s.GetParentMap(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetParentMap: %v", err)
|
|
}
|
|
if m[child.ID] != parent.ID {
|
|
t.Fatalf("expected parent map %s→%s, got %s→%s", child.ID, parent.ID, child.ID, m[child.ID])
|
|
}
|
|
|
|
// Soft-delete the parent. The child must now look "parentless" so the
|
|
// dashboard orphan detector flags it.
|
|
if err := s.DeleteItem(parent.ID); err != nil {
|
|
t.Fatalf("DeleteItem: %v", err)
|
|
}
|
|
m, err = s.GetParentMap(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetParentMap after parent delete: %v", err)
|
|
}
|
|
if _, hasEntry := m[child.ID]; hasEntry {
|
|
t.Errorf("expected child to drop from parent map after parent soft-deleted (orphan-detection regression)")
|
|
}
|
|
|
|
// Restoring the parent should bring the mapping back.
|
|
if _, err := s.RestoreItem(parent.ID); err != nil {
|
|
t.Fatalf("RestoreItem: %v", err)
|
|
}
|
|
m, err = s.GetParentMap(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetParentMap after parent restore: %v", err)
|
|
}
|
|
if m[child.ID] != parent.ID {
|
|
t.Errorf("expected parent map to be restored to %s→%s, got %s→%s", child.ID, parent.ID, child.ID, m[child.ID])
|
|
}
|
|
|
|
// Soft-deleting the child side should also drop the entry.
|
|
if err := s.DeleteItem(child.ID); err != nil {
|
|
t.Fatalf("DeleteItem child: %v", err)
|
|
}
|
|
m, err = s.GetParentMap(ws.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetParentMap after child delete: %v", err)
|
|
}
|
|
if _, hasEntry := m[child.ID]; hasEntry {
|
|
t.Errorf("expected child to drop from parent map after the child itself was soft-deleted")
|
|
}
|
|
}
|
|
|
|
// TestListItems_ParentFilter_FTS_RespectsSoftDeletedParent covers the
|
|
// `parent=<UUID>&search=<q>` combination. The search path routes through
|
|
// listItemsFTS, which the non-FTS parent filter doesn't touch; the FTS
|
|
// path needs to enforce the same deleted-parent rejection. See BUG-734 /
|
|
// Codex review on PR #259 (3rd pass).
|
|
func TestListItems_ParentFilter_FTS_RespectsSoftDeletedParent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
parent := createTestItem(t, s, ws.ID, col.ID, "Parent", "")
|
|
// Use a distinctive title so the FTS match is unambiguous.
|
|
child := createTestItem(t, s, ws.ID, col.ID, "Distinctivekeyword child", "")
|
|
if _, err := s.SetParentLink(ws.ID, child.ID, parent.ID, "user"); err != nil {
|
|
t.Fatalf("SetParentLink: %v", err)
|
|
}
|
|
|
|
// Sanity: search + parent finds the child while parent is live.
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{
|
|
ParentLinkID: parent.ID,
|
|
Search: "Distinctivekeyword",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems (FTS+parent): %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != child.ID {
|
|
t.Fatalf("expected to find 1 child via FTS+parent before delete, got %d", len(items))
|
|
}
|
|
|
|
// Soft-delete the parent. The FTS path must also reject the now-deleted
|
|
// parent, otherwise `?parent=<deleted-uuid>&search=foo` continues to leak
|
|
// active children of an archived parent (the gap Codex flagged).
|
|
if err := s.DeleteItem(parent.ID); err != nil {
|
|
t.Fatalf("DeleteItem: %v", err)
|
|
}
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{
|
|
ParentLinkID: parent.ID,
|
|
Search: "Distinctivekeyword",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems (FTS+parent) after delete: %v", err)
|
|
}
|
|
if len(items) != 0 {
|
|
t.Errorf("expected 0 children via FTS+parent after parent soft-deleted, got %d (FTS-path parent-filter regression)", len(items))
|
|
}
|
|
|
|
// Restore brings the child back through the FTS+parent path.
|
|
if _, err := s.RestoreItem(parent.ID); err != nil {
|
|
t.Fatalf("RestoreItem: %v", err)
|
|
}
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{
|
|
ParentLinkID: parent.ID,
|
|
Search: "Distinctivekeyword",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems (FTS+parent) after restore: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != child.ID {
|
|
t.Errorf("expected child to reappear via FTS+parent after restoring parent, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
// TestListItems_ParentFilter_RespectsSoftDeletedParent ensures the
|
|
// `parent=<UUID>` query filter doesn't return children of a soft-deleted
|
|
// parent. Slug/ref filters already reject deleted parents upstream via
|
|
// GetItem/GetItemBySlug, but raw-UUID input bypasses that path. See
|
|
// BUG-734 / Codex review on PR #259.
|
|
func TestListItems_ParentFilter_RespectsSoftDeletedParent(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
parent := createTestItem(t, s, ws.ID, col.ID, "Parent", "")
|
|
child := createTestItem(t, s, ws.ID, col.ID, "Child", "")
|
|
if _, err := s.SetParentLink(ws.ID, child.ID, parent.ID, "user"); err != nil {
|
|
t.Fatalf("SetParentLink: %v", err)
|
|
}
|
|
|
|
// Sanity: child is reachable via the parent filter.
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{ParentLinkID: parent.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != child.ID {
|
|
t.Fatalf("expected to find 1 child via parent filter before delete, got %+v", items)
|
|
}
|
|
|
|
// Soft-delete the parent. Filter must now return no children — no
|
|
// caller should be able to list children of a deleted parent by UUID.
|
|
if err := s.DeleteItem(parent.ID); err != nil {
|
|
t.Fatalf("DeleteItem: %v", err)
|
|
}
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{ParentLinkID: parent.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems after parent delete: %v", err)
|
|
}
|
|
if len(items) != 0 {
|
|
t.Errorf("expected 0 children after parent soft-deleted, got %d (parent-filter regression)", len(items))
|
|
}
|
|
|
|
// Restoring the parent should bring the child back into the filter.
|
|
if _, err := s.RestoreItem(parent.ID); err != nil {
|
|
t.Fatalf("RestoreItem: %v", err)
|
|
}
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{ParentLinkID: parent.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems after restore: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != child.ID {
|
|
t.Errorf("expected 1 child after restoring parent, got %d", len(items))
|
|
}
|
|
}
|
|
|
|
// TestListItems_FTS_HyphenatedSearchTerm pins BUG-818 (FTS5 boolean parser
|
|
// regression on bare hyphens) AND BUG-842 (PG plainto_tsquery missing
|
|
// hyphenated-word matches). Each subtest indexes a distinct title and
|
|
// searches for a hyphenated form; both backends MUST return the match.
|
|
//
|
|
// The two cases exercise the two failure modes:
|
|
// - `task-five` against `task-five-distinctive`: PG indexes the full
|
|
// asciihword as `task-five-distinct`; a naive plainto_tsquery on the
|
|
// query produces `task-fiv` (stemmed asciihword for the partial
|
|
// query) which is NOT in the vector. Fixed by ORing in a
|
|
// hyphen-as-space variant — see sanitizePGFTSQuery.
|
|
// - `BUG-842` against `BUG-842 fix the cleanup race`: PG indexes the
|
|
// `-842` token (negative number lexeme); a hyphen-as-space variant
|
|
// would search for `842` and miss. The OR-combined query keeps the
|
|
// raw form alive too, so `-842` matches.
|
|
func TestListItems_FTS_HyphenatedSearchTerm(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Two distinctive titles, each exercising one tokenization mode.
|
|
wantFive := createTestItem(t, s, ws.ID, col.ID, "task-five-distinctive", "")
|
|
wantBug := createTestItem(t, s, ws.ID, col.ID, "BUG-842 fix the cleanup race", "")
|
|
// Plain non-matching item.
|
|
createTestItem(t, s, ws.ID, col.ID, "unrelated thing", "")
|
|
|
|
cases := []struct {
|
|
query string
|
|
want *models.Item
|
|
}{
|
|
{"task-five-distinctive", wantFive}, // full slug-ish
|
|
{"task-five", wantFive}, // partial hyphenated — BUG-842 PG case
|
|
{"BUG-842", wantBug}, // word-NUMBER hyphen — would regress under naive sanitize
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.query, func(t *testing.T) {
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: tc.query})
|
|
if err != nil {
|
|
t.Fatalf("ListItems(search=%q) errored (FTS5 boolean parser regression?): %v", tc.query, err)
|
|
}
|
|
if len(items) == 0 {
|
|
t.Fatalf("ListItems(search=%q) returned 0 items, expected to find %s", tc.query, tc.want.Title)
|
|
}
|
|
found := false
|
|
for _, it := range items {
|
|
if it.ID == tc.want.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("ListItems(search=%q) didn't include %s, got %d items", tc.query, tc.want.Title, len(items))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSearchItems_HyphenatedQuery is the BUG-818 regression test on the
|
|
// /api/v1/search code path, which routes through Store.SearchItems instead
|
|
// of Store.listItemsFTS.
|
|
func TestSearchItems_HyphenatedQuery(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
want := createTestItem(t, s, ws.ID, col.ID, "alpha-beta-gamma marker", "")
|
|
|
|
results, err := s.SearchItems(ws.ID, "alpha-beta-gamma")
|
|
if err != nil {
|
|
t.Fatalf("SearchItems errored on hyphenated query: %v", err)
|
|
}
|
|
if len(results) == 0 {
|
|
t.Fatalf("SearchItems returned 0 results, expected to find %s", want.Title)
|
|
}
|
|
found := false
|
|
for _, r := range results {
|
|
if r.Item.ID == want.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("SearchItems didn't include %s, got %d results", want.Title, len(results))
|
|
}
|
|
}
|
|
|
|
// TestListDocuments_HyphenatedQuery covers BUG-818 on the documents FTS path.
|
|
// Same root cause as items: hyphenated queries hit FTS5's boolean parser and
|
|
// 500 unless sanitized. Surfaces /documents?q=task-5 and the web UI doc list.
|
|
func TestListDocuments_HyphenatedQuery(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
want, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "release-notes-q2",
|
|
Content: "Quarterly release notes for the Q2 2026 milestone.",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
if _, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "unrelated topic",
|
|
Content: "Nothing matching.",
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument unrelated: %v", err)
|
|
}
|
|
|
|
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: "release-notes-q2"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments(query=hyphenated) errored (FTS5 boolean parser regression?): %v", err)
|
|
}
|
|
if len(docs) == 0 {
|
|
t.Fatalf("ListDocuments returned 0 docs, expected to find %s", want.Title)
|
|
}
|
|
found := false
|
|
for _, d := range docs {
|
|
if d.ID == want.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("ListDocuments didn't include %s, got %d docs", want.Title, len(docs))
|
|
}
|
|
}
|
|
|
|
// TestStartupInvariants_AllFTSTriggersExist asserts every trigger in the
|
|
// canonical expectedFTSTriggers list is present after migrations run on a
|
|
// fresh DB. This is a forward-looking guard — any future migration that
|
|
// inadvertently breaks one of these will fail this test, before it can
|
|
// silently drift on production DBs the way BUG-822 did.
|
|
func TestStartupInvariants_AllFTSTriggersExist(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
if s.dialect.Driver() != DriverSQLite {
|
|
t.Skip("FTS triggers are SQLite-specific; Postgres uses a different model")
|
|
}
|
|
|
|
for _, want := range expectedFTSTriggers {
|
|
var name string
|
|
err := s.db.QueryRow(
|
|
`SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name=? AND name=?`,
|
|
want.table, want.name,
|
|
).Scan(&name)
|
|
if err != nil {
|
|
t.Errorf("expected trigger %q on table %q to exist after migrations, got error: %v",
|
|
want.name, want.table, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestExpectedFTSTriggers_MatchesActual catches drift in the *opposite*
|
|
// direction from TestStartupInvariants_AllFTSTriggersExist: if a future
|
|
// migration adds a new trigger on items / comments / documents and the
|
|
// author forgets to add it to expectedFTSTriggers, the invariant check
|
|
// won't know to monitor it. This test compares the actual set of triggers
|
|
// on those tables against expectedFTSTriggers and fails if anything is
|
|
// missing from the list.
|
|
//
|
|
// If a new non-FTS trigger is legitimately added to one of these tables,
|
|
// either add it to expectedFTSTriggers (if it serves an FTS-like role) or
|
|
// extend the exclusion below.
|
|
func TestExpectedFTSTriggers_MatchesActual(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
if s.dialect.Driver() != DriverSQLite {
|
|
t.Skip("FTS triggers are SQLite-specific")
|
|
}
|
|
|
|
expected := map[string]bool{}
|
|
for _, e := range expectedFTSTriggers {
|
|
expected[e.table+"/"+e.name] = true
|
|
}
|
|
|
|
rows, err := s.db.Query(`
|
|
SELECT name, tbl_name FROM sqlite_master
|
|
WHERE type='trigger' AND tbl_name IN ('items', 'comments', 'documents')
|
|
`)
|
|
if err != nil {
|
|
t.Fatalf("query triggers: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
for rows.Next() {
|
|
var name, table string
|
|
if err := rows.Scan(&name, &table); err != nil {
|
|
t.Fatalf("scan: %v", err)
|
|
}
|
|
key := table + "/" + name
|
|
if !expected[key] {
|
|
t.Errorf("found trigger %q on table %q that's not in expectedFTSTriggers — "+
|
|
"update the list in store.go (or exclude in this test if it's not an FTS-style trigger)",
|
|
name, table)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStartupInvariants_LogsOnMissingTrigger verifies the alarm actually
|
|
// fires: drop a trigger, run validateFTSInvariants, capture the slog
|
|
// output, assert a warning was emitted naming the missing trigger.
|
|
//
|
|
// Without this test, the validator could regress (e.g. a typo in the
|
|
// SELECT, a dialect check that always returns early) and the BUG-822
|
|
// class of drift would go undetected again.
|
|
func TestStartupInvariants_LogsOnMissingTrigger(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
if s.dialect.Driver() != DriverSQLite {
|
|
t.Skip("FTS trigger invariant check runs only on SQLite")
|
|
}
|
|
|
|
// Drop a known trigger to simulate the BUG-822 broken state.
|
|
target := "documents_ai"
|
|
if _, err := s.db.Exec("DROP TRIGGER " + target); err != nil {
|
|
t.Fatalf("DROP TRIGGER %s: %v", target, err)
|
|
}
|
|
|
|
// Capture slog output via a custom handler that records records into
|
|
// a slice we can inspect after.
|
|
var captured []slog.Record
|
|
prev := slog.Default()
|
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
|
slog.SetDefault(slog.New(&recordCapturingHandler{records: &captured}))
|
|
|
|
s.validateFTSInvariants()
|
|
|
|
// Look for a warning record that mentions the trigger name we dropped.
|
|
found := false
|
|
for _, r := range captured {
|
|
if r.Level != slog.LevelWarn {
|
|
continue
|
|
}
|
|
mentioned := false
|
|
r.Attrs(func(a slog.Attr) bool {
|
|
if a.Key == "trigger" && a.Value.String() == target {
|
|
mentioned = true
|
|
return false
|
|
}
|
|
return true
|
|
})
|
|
if mentioned {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("expected a slog.Warn record naming trigger=%q after dropping it, got %d records", target, len(captured))
|
|
for _, r := range captured {
|
|
t.Logf(" %s: %s", r.Level, r.Message)
|
|
}
|
|
}
|
|
}
|
|
|
|
// recordCapturingHandler is a minimal slog.Handler used by
|
|
// TestStartupInvariants_LogsOnMissingTrigger to capture records without
|
|
// emitting them to stderr. Not safe for concurrent use; tests are
|
|
// single-threaded.
|
|
type recordCapturingHandler struct {
|
|
records *[]slog.Record
|
|
attrs []slog.Attr
|
|
}
|
|
|
|
func (h *recordCapturingHandler) Enabled(_ context.Context, _ slog.Level) bool {
|
|
return true
|
|
}
|
|
func (h *recordCapturingHandler) Handle(_ context.Context, r slog.Record) error {
|
|
// slog.Record has internal shared state; clone before retaining so we
|
|
// don't depend on the caller refraining from mutating it after Handle.
|
|
r = r.Clone()
|
|
for _, a := range h.attrs {
|
|
r.AddAttrs(a)
|
|
}
|
|
*h.records = append(*h.records, r)
|
|
return nil
|
|
}
|
|
func (h *recordCapturingHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
clone := *h
|
|
clone.attrs = append(append([]slog.Attr{}, h.attrs...), attrs...)
|
|
return &clone
|
|
}
|
|
func (h *recordCapturingHandler) WithGroup(_ string) slog.Handler {
|
|
return h
|
|
}
|
|
|
|
// TestMigration046_DocumentsFTSTriggersExist verifies that after all
|
|
// migrations run on a fresh DB, the three documents_* triggers are present.
|
|
// This regression-protects BUG-822 — production DBs ended up missing these
|
|
// triggers (likely from a transient quirk during migration 025), and
|
|
// migration 046 restores them.
|
|
func TestMigration046_DocumentsFTSTriggersExist(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
// SQLite-only — Postgres uses a different tsvector trigger setup and
|
|
// is unaffected.
|
|
if s.dialect.Driver() != DriverSQLite {
|
|
t.Skip("documents_ai/au/ad triggers are SQLite-FTS5 specific")
|
|
}
|
|
|
|
for _, want := range []string{"documents_ai", "documents_au", "documents_ad"} {
|
|
var name string
|
|
err := s.db.QueryRow(
|
|
"SELECT name FROM sqlite_master WHERE type='trigger' AND tbl_name='documents' AND name=?",
|
|
want,
|
|
).Scan(&name)
|
|
if err != nil {
|
|
t.Errorf("expected trigger %q to exist after migrations, got error: %v", want, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestMigration046_RebuildRecoversUnindexedDocs pins the *recovery* half of
|
|
// migration 046 — the part that rescues already-broken DBs. We simulate the
|
|
// production state Codex flagged: triggers were missing, so a document was
|
|
// inserted into the documents table but never made it into documents_fts.
|
|
// Migration 046's `INSERT INTO documents_fts(documents_fts) VALUES('rebuild')`
|
|
// step is what makes such a document searchable again. Without this test,
|
|
// removing the rebuild step would not break either of the other two BUG-822
|
|
// tests — flagged in Codex review.
|
|
func TestMigration046_RebuildRecoversUnindexedDocs(t *testing.T) {
|
|
s := testStore(t)
|
|
|
|
if s.dialect.Driver() != DriverSQLite {
|
|
t.Skip("FTS5 rebuild idiom is SQLite-specific")
|
|
}
|
|
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
// Simulate the BUG-822 broken state: drop the FTS triggers so subsequent
|
|
// inserts don't propagate into documents_fts.
|
|
for _, name := range []string{"documents_ai", "documents_au", "documents_ad"} {
|
|
if _, err := s.db.Exec("DROP TRIGGER IF EXISTS " + name); err != nil {
|
|
t.Fatalf("DROP TRIGGER %s: %v", name, err)
|
|
}
|
|
}
|
|
|
|
// Insert via the normal store path; trigger is gone so it won't reach FTS.
|
|
doc, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "Bug822recoverable distinctive",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
|
|
// Pin the broken state — the doc must NOT be searchable yet, otherwise
|
|
// the test isn't actually exercising the recovery path.
|
|
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: "Bug822recoverable"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments (pre-rebuild): %v", err)
|
|
}
|
|
if len(docs) != 0 {
|
|
t.Fatalf("test setup invalid: expected doc to be invisible to FTS pre-rebuild, got %d results", len(docs))
|
|
}
|
|
|
|
// Run just the rebuild step from migration 046 — the recovery action.
|
|
if _, err := s.db.Exec(`INSERT INTO documents_fts(documents_fts) VALUES ('rebuild')`); err != nil {
|
|
t.Fatalf("FTS rebuild: %v", err)
|
|
}
|
|
|
|
// Now the previously-unindexed doc must be findable.
|
|
docs, err = s.ListDocuments(ws.ID, models.DocumentListParams{Query: "Bug822recoverable"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments (post-rebuild): %v", err)
|
|
}
|
|
if len(docs) != 1 || docs[0].ID != doc.ID {
|
|
t.Errorf("expected doc to become searchable post-rebuild, got %d results", len(docs))
|
|
}
|
|
}
|
|
|
|
// TestCreateDocument_IsSearchableImmediately is the BUG-822 regression test:
|
|
// a freshly-created document must be findable via FTS without any manual
|
|
// rebuild. This was failing on production DBs whose documents_* triggers
|
|
// were missing — Store.CreateDocument inserted into documents but the
|
|
// after-insert trigger never fired to populate documents_fts, leaving the
|
|
// new doc invisible to ListDocuments(Query=...).
|
|
func TestCreateDocument_IsSearchableImmediately(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
doc, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "uniquesearchableword scratch",
|
|
Content: "body",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
|
|
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: "uniquesearchableword"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments: %v", err)
|
|
}
|
|
if len(docs) == 0 {
|
|
t.Fatalf("expected to find newly-created doc by FTS, got 0 results (the BUG-822 regression)")
|
|
}
|
|
found := false
|
|
for _, d := range docs {
|
|
if d.ID == doc.ID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
t.Errorf("FTS results don't include the new doc, got %d unrelated results", len(docs))
|
|
}
|
|
}
|
|
|
|
// TestListDocuments_FTS_TagFilter pins BUG-820: when a search query is set,
|
|
// the FTS branch must still re-apply Tag filters (the documents analog of
|
|
// BUG-812). Before the fix, /documents?q=foo&tag=bar returned all docs
|
|
// matching "foo" regardless of tag.
|
|
func TestListDocuments_FTS_TagFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
tagged, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "FTSdocfilter alpha",
|
|
Tags: `["urgent"]`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateDocument tagged: %v", err)
|
|
}
|
|
if _, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "FTSdocfilter beta",
|
|
Tags: `[]`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument untagged: %v", err)
|
|
}
|
|
|
|
// Sanity: search alone returns both.
|
|
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: "FTSdocfilter"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments sanity: %v", err)
|
|
}
|
|
if len(docs) != 2 {
|
|
t.Fatalf("sanity expected 2 docs via search, got %d", len(docs))
|
|
}
|
|
|
|
// Search + tag must narrow to the tagged doc only.
|
|
docs, err = s.ListDocuments(ws.ID, models.DocumentListParams{Query: "FTSdocfilter", Tag: "urgent"})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments search+tag: %v", err)
|
|
}
|
|
if len(docs) != 1 || docs[0].ID != tagged.ID {
|
|
t.Errorf("expected exactly the tagged doc, got %d docs", len(docs))
|
|
}
|
|
}
|
|
|
|
// TestListDocuments_FTS_PinnedFilter pins BUG-820 for the pinned filter:
|
|
// /documents?q=foo&pinned=true used to ignore the pin bit when the FTS
|
|
// branch took over. Documents analog of BUG-812.
|
|
func TestListDocuments_FTS_PinnedFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
|
|
pinned, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "FTSpindoc alpha",
|
|
Pinned: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateDocument pinned: %v", err)
|
|
}
|
|
if _, err := s.CreateDocument(ws.ID, models.DocumentCreate{
|
|
Title: "FTSpindoc beta",
|
|
Pinned: false,
|
|
}); err != nil {
|
|
t.Fatalf("CreateDocument unpinned: %v", err)
|
|
}
|
|
|
|
pinTrue := true
|
|
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: "FTSpindoc", Pinned: &pinTrue})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments search+pinned=true: %v", err)
|
|
}
|
|
if len(docs) != 1 || docs[0].ID != pinned.ID {
|
|
t.Errorf("expected exactly the pinned doc via search+pinned=true, got %d docs", len(docs))
|
|
}
|
|
|
|
// And the inverse: pinned=false narrows to the other one.
|
|
pinFalse := false
|
|
docs, err = s.ListDocuments(ws.ID, models.DocumentListParams{Query: "FTSpindoc", Pinned: &pinFalse})
|
|
if err != nil {
|
|
t.Fatalf("ListDocuments search+pinned=false: %v", err)
|
|
}
|
|
if len(docs) != 1 || docs[0].Pinned {
|
|
t.Errorf("expected exactly the unpinned doc via search+pinned=false, got %d docs", len(docs))
|
|
}
|
|
}
|
|
|
|
// TestFTS_WhitespaceOnlyQuery_DoesNotCrash exercises the whitespace-only
|
|
// guard on each FTS entry point. sanitizeFTSQuery turns " " into "" and
|
|
// SQLite FTS5 errors on a MATCH against an empty string, so the
|
|
// routing/guard has to short-circuit before binding.
|
|
// See BUG-818 / Codex follow-up.
|
|
func TestFTS_WhitespaceOnlyQuery_DoesNotCrash(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
createTestItem(t, s, ws.ID, col.ID, "anything", "")
|
|
if _, err := s.CreateDocument(ws.ID, models.DocumentCreate{Title: "anything"}); err != nil {
|
|
t.Fatalf("CreateDocument: %v", err)
|
|
}
|
|
|
|
for _, q := range []string{" ", "\t", "\n \t"} {
|
|
if _, err := s.ListItems(ws.ID, models.ItemListParams{Search: q}); err != nil {
|
|
t.Errorf("ListItems(search=%q) errored: %v", q, err)
|
|
}
|
|
if _, err := s.SearchItems(ws.ID, q); err != nil {
|
|
t.Errorf("SearchItems(%q) errored: %v", q, err)
|
|
}
|
|
if _, err := s.ListDocuments(ws.ID, models.DocumentListParams{Query: q}); err != nil {
|
|
t.Errorf("ListDocuments(query=%q) errored: %v", q, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestSanitizePGFTSQuery is a unit test for the BUG-842 helper that
|
|
// produces the second leg of the OR-combined PG FTS query (raw +
|
|
// hyphen-as-space). See internal/store/search.go.
|
|
func TestSanitizePGFTSQuery(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"empty", "", ""},
|
|
{"plain word", "hello", "hello"},
|
|
{"hyphenated phrase", "task-five", "task five"},
|
|
{"BUG-842 form", "BUG-842", "BUG 842"},
|
|
{"multiple hyphens", "task-five-distinctive", "task five distinctive"},
|
|
{"multi-token with hyphens", "task-five other-thing", "task five other thing"},
|
|
{"no hyphens preserved", "foo bar baz", "foo bar baz"},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizePGFTSQuery(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizePGFTSQuery(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestSanitizeFTSQuery is a unit test for the helper that wraps each token in
|
|
// double quotes so SQLite FTS5 treats special characters as literals. See
|
|
// internal/store/search.go and BUG-818.
|
|
func TestSanitizeFTSQuery(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"empty", "", ""},
|
|
{"whitespace only", " ", ""},
|
|
{"plain word", "hello", `"hello"`},
|
|
{"hyphenated phrase", "task-5", `"task-5"`},
|
|
{"multiple tokens", "foo bar", `"foo" "bar"`},
|
|
{"FTS5 boolean operator", "foo AND bar", `"foo" "AND" "bar"`},
|
|
{"NOT operator", "foo NOT bar", `"foo" "NOT" "bar"`},
|
|
{"parens", "(foo OR bar)", `"(foo" "OR" "bar)"`},
|
|
{"embedded quotes are stripped", `"foo"bar`, `"foobar"`},
|
|
{"surrounding whitespace trimmed", " hello ", `"hello"`},
|
|
{"unicode token preserved", "café-au-lait", `"café-au-lait"`},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
got := sanitizeFTSQuery(tc.in)
|
|
if got != tc.want {
|
|
t.Errorf("sanitizeFTSQuery(%q) = %q, want %q", tc.in, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// --- BUG-812: listItemsFTS filter parity ---
|
|
//
|
|
// listItemsFTS used to silently drop most non-collection filters when the
|
|
// search param was set, so combining `?search=...&tag=...&assigned_user=...`
|
|
// (etc.) returned more items than the caller asked for. These tests pin the
|
|
// fix: each filter, applied alongside an FTS search query that matches both
|
|
// items, must narrow the result to only the matching item.
|
|
|
|
func TestListItems_FTS_TagFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
tagged, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword tagged",
|
|
Fields: `{"status":"open"}`,
|
|
Tags: `["urgent"]`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem tagged: %v", err)
|
|
}
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword untagged",
|
|
Fields: `{"status":"open"}`,
|
|
Tags: `[]`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateItem untagged: %v", err)
|
|
}
|
|
|
|
// Sanity: search alone returns both.
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems sanity: %v", err)
|
|
}
|
|
if len(items) != 2 {
|
|
t.Fatalf("sanity expected 2 items via search, got %d", len(items))
|
|
}
|
|
|
|
// Search + tag must narrow to the tagged item only.
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword", Tag: "urgent"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+tag: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != tagged.ID {
|
|
t.Errorf("expected exactly the tagged item, got %d items", len(items))
|
|
}
|
|
}
|
|
|
|
func TestListItems_FTS_ParentIDFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
parent := createTestItem(t, s, ws.ID, col.ID, "Parent item", "")
|
|
parentID := parent.ID
|
|
|
|
withParent, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword child",
|
|
Fields: `{"status":"open"}`,
|
|
ParentID: &parentID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem with parent: %v", err)
|
|
}
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword sibling-without-parent",
|
|
Fields: `{"status":"open"}`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateItem without parent: %v", err)
|
|
}
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword", ParentID: parent.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+parentID: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != withParent.ID {
|
|
t.Errorf("expected exactly the child of parent, got %d items", len(items))
|
|
}
|
|
}
|
|
|
|
func TestListItems_FTS_AssignedUserFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
user := createTestUser(t, s, "fts-assignee@test.com", "FTS Assignee", "password123")
|
|
if err := s.AddWorkspaceMember(ws.ID, user.ID, "editor"); err != nil {
|
|
t.Fatalf("AddWorkspaceMember: %v", err)
|
|
}
|
|
uid := user.ID
|
|
|
|
assigned, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword assigned",
|
|
Fields: `{"status":"open"}`,
|
|
AssignedUserID: &uid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem assigned: %v", err)
|
|
}
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword unassigned",
|
|
Fields: `{"status":"open"}`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateItem unassigned: %v", err)
|
|
}
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword", AssignedUserID: user.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+assignee: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != assigned.ID {
|
|
t.Errorf("expected exactly the assigned item, got %d items", len(items))
|
|
}
|
|
}
|
|
|
|
func TestListItems_FTS_AgentRoleFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
role, err := s.CreateAgentRole(ws.ID, models.AgentRoleCreate{
|
|
Name: "Implementer",
|
|
Slug: "implementer",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateAgentRole: %v", err)
|
|
}
|
|
rid := role.ID
|
|
|
|
withRole, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword role-bearing",
|
|
Fields: `{"status":"open"}`,
|
|
AgentRoleID: &rid,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem with role: %v", err)
|
|
}
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword no-role",
|
|
Fields: `{"status":"open"}`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateItem without role: %v", err)
|
|
}
|
|
|
|
// Filter by role ID — exercises the i.agent_role_id = ? branch.
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword", AgentRoleID: role.ID})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+role-by-id: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != withRole.ID {
|
|
t.Errorf("expected the role-bearing item via role-ID filter, got %d items", len(items))
|
|
}
|
|
|
|
// Filter by role slug — exercises the OR ar.slug = ? branch.
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{Search: "Searchhitkeyword", AgentRoleID: "implementer"})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+role-by-slug: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != withRole.ID {
|
|
t.Errorf("expected the role-bearing item via role-slug filter, got %d items", len(items))
|
|
}
|
|
}
|
|
|
|
func TestListItems_FTS_FieldFilter(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
high, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword high-priority",
|
|
Fields: `{"status":"open","priority":"high"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem high: %v", err)
|
|
}
|
|
medium, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword medium-priority",
|
|
Fields: `{"status":"open","priority":"medium"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CreateItem medium: %v", err)
|
|
}
|
|
if _, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "Searchhitkeyword low-priority",
|
|
Fields: `{"status":"open","priority":"low"}`,
|
|
}); err != nil {
|
|
t.Fatalf("CreateItem low: %v", err)
|
|
}
|
|
|
|
// Single-value field filter — narrows to 1.
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{
|
|
Search: "Searchhitkeyword",
|
|
Fields: map[string]string{"priority": "high"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+field=high: %v", err)
|
|
}
|
|
if len(items) != 1 || items[0].ID != high.ID {
|
|
t.Errorf("expected exactly the high-priority item, got %d items", len(items))
|
|
}
|
|
|
|
// Comma-separated — narrows to 2 (high + medium).
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{
|
|
Search: "Searchhitkeyword",
|
|
Fields: map[string]string{"priority": "high,medium"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+field=high,medium: %v", err)
|
|
}
|
|
gotIDs := map[string]bool{}
|
|
for _, it := range items {
|
|
gotIDs[it.ID] = true
|
|
}
|
|
if len(items) != 2 || !gotIDs[high.ID] || !gotIDs[medium.ID] {
|
|
t.Errorf("expected high+medium via IN clause, got %d items: %+v", len(items), gotIDs)
|
|
}
|
|
|
|
// Invalid field key must be silently ignored (isValidFieldKey rejects),
|
|
// not crash or return zero results. The non-FTS path has the same
|
|
// guarantee.
|
|
items, err = s.ListItems(ws.ID, models.ItemListParams{
|
|
Search: "Searchhitkeyword",
|
|
Fields: map[string]string{"bad key with spaces": "anything"},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListItems search+invalid-field-key: %v", err)
|
|
}
|
|
// 3 because the invalid key is dropped — search alone returns all 3.
|
|
if len(items) != 3 {
|
|
t.Errorf("expected invalid field key to be ignored (3 search hits), got %d items", len(items))
|
|
}
|
|
}
|
|
|
|
func TestItemLinkDefaultType(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item1 := createTestItem(t, s, ws.ID, col.ID, "Task A", "")
|
|
item2 := createTestItem(t, s, ws.ID, col.ID, "Task B", "")
|
|
|
|
// Create link without explicit type
|
|
link, err := s.CreateItemLink(ws.ID, models.ItemLinkCreate{
|
|
TargetID: item2.ID,
|
|
}, item1.ID)
|
|
if err != nil {
|
|
t.Fatalf("CreateItemLink error: %v", err)
|
|
}
|
|
if link.LinkType != "related" {
|
|
t.Errorf("expected default link_type 'related', got %q", link.LinkType)
|
|
}
|
|
}
|
|
|
|
// --- Workspace-Global Item Numbering Tests ---
|
|
|
|
func TestItemNumbersAreWorkspaceGlobal(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
tasks := createTestCollection(t, s, ws.ID, "Tasks")
|
|
ideas := createTestCollection(t, s, ws.ID, "Ideas")
|
|
|
|
// Create items across two collections — numbers should be globally sequential
|
|
t1 := createTestItem(t, s, ws.ID, tasks.ID, "Task 1", "")
|
|
i1 := createTestItem(t, s, ws.ID, ideas.ID, "Idea 1", "")
|
|
t2 := createTestItem(t, s, ws.ID, tasks.ID, "Task 2", "")
|
|
i2 := createTestItem(t, s, ws.ID, ideas.ID, "Idea 2", "")
|
|
|
|
if *t1.ItemNumber != 1 {
|
|
t.Errorf("expected Task 1 to be #1, got #%d", *t1.ItemNumber)
|
|
}
|
|
if *i1.ItemNumber != 2 {
|
|
t.Errorf("expected Idea 1 to be #2, got #%d", *i1.ItemNumber)
|
|
}
|
|
if *t2.ItemNumber != 3 {
|
|
t.Errorf("expected Task 2 to be #3, got #%d", *t2.ItemNumber)
|
|
}
|
|
if *i2.ItemNumber != 4 {
|
|
t.Errorf("expected Idea 2 to be #4, got #%d", *i2.ItemNumber)
|
|
}
|
|
}
|
|
|
|
func TestMoveItemPreservesNumber(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
tasks := createTestCollection(t, s, ws.ID, "Tasks")
|
|
bugs := createTestCollection(t, s, ws.ID, "Bugs")
|
|
|
|
item := createTestItem(t, s, ws.ID, tasks.ID, "Fix something", "")
|
|
originalNumber := *item.ItemNumber
|
|
|
|
// Move from Tasks to Bugs
|
|
moved, err := s.MoveItem(item.ID, bugs.ID, `{"status":"open"}`)
|
|
if err != nil {
|
|
t.Fatalf("MoveItem error: %v", err)
|
|
}
|
|
|
|
if moved.CollectionID != bugs.ID {
|
|
t.Error("item should be in bugs collection after move")
|
|
}
|
|
if *moved.ItemNumber != originalNumber {
|
|
t.Errorf("item number should be preserved after move: expected %d, got %d", originalNumber, *moved.ItemNumber)
|
|
}
|
|
|
|
// Verify the ref changed prefix but kept the number
|
|
if moved.Ref != fmt.Sprintf("%s-%d", bugs.Prefix, originalNumber) {
|
|
t.Errorf("expected ref %s-%d, got %s", bugs.Prefix, originalNumber, moved.Ref)
|
|
}
|
|
}
|
|
|
|
func TestOldRefResolvesAfterMove(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
plans := createTestCollection(t, s, ws.ID, "Plans")
|
|
tasks := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createTestItem(t, s, ws.ID, plans.ID, "My Plan", "")
|
|
originalNumber := *item.ItemNumber
|
|
|
|
// Item is currently PLAN-1
|
|
found, err := s.GetItemByRef(ws.ID, "PLAN", originalNumber)
|
|
if err != nil {
|
|
t.Fatalf("GetItemByRef error: %v", err)
|
|
}
|
|
if found == nil || found.ID != item.ID {
|
|
t.Fatal("expected to find item by PLAN ref before move")
|
|
}
|
|
|
|
// Move to Tasks — becomes TASK-1
|
|
moved, err := s.MoveItem(item.ID, tasks.ID, `{"status":"open"}`)
|
|
if err != nil {
|
|
t.Fatalf("MoveItem error: %v", err)
|
|
}
|
|
if moved.Ref != fmt.Sprintf("TASK-%d", originalNumber) {
|
|
t.Fatalf("expected ref TASK-%d after move, got %s", originalNumber, moved.Ref)
|
|
}
|
|
|
|
// Old ref PLAN-1 should STILL resolve to the same item (fallback by number)
|
|
found, err = s.GetItemByRef(ws.ID, "PLAN", originalNumber)
|
|
if err != nil {
|
|
t.Fatalf("GetItemByRef (old ref) error: %v", err)
|
|
}
|
|
if found == nil {
|
|
t.Fatal("old ref PLAN-N should still resolve after move")
|
|
}
|
|
if found.ID != item.ID {
|
|
t.Error("old ref resolved to wrong item")
|
|
}
|
|
|
|
// New ref TASK-1 should also work
|
|
found, err = s.GetItemByRef(ws.ID, "TASK", originalNumber)
|
|
if err != nil {
|
|
t.Fatalf("GetItemByRef (new ref) error: %v", err)
|
|
}
|
|
if found == nil || found.ID != item.ID {
|
|
t.Fatal("new ref TASK-N should resolve after move")
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceNumberingIsolation(t *testing.T) {
|
|
s := testStore(t)
|
|
ws1 := createTestWorkspace(t, s, "Workspace 1")
|
|
ws2 := createTestWorkspace(t, s, "Workspace 2")
|
|
col1 := createTestCollection(t, s, ws1.ID, "Tasks")
|
|
col2 := createTestCollection(t, s, ws2.ID, "Tasks")
|
|
|
|
// Each workspace has its own counter starting at 1
|
|
item1 := createTestItem(t, s, ws1.ID, col1.ID, "WS1 Task", "")
|
|
item2 := createTestItem(t, s, ws2.ID, col2.ID, "WS2 Task", "")
|
|
|
|
if *item1.ItemNumber != 1 {
|
|
t.Errorf("expected WS1 item to be #1, got #%d", *item1.ItemNumber)
|
|
}
|
|
if *item2.ItemNumber != 1 {
|
|
t.Errorf("expected WS2 item to be #1, got #%d", *item2.ItemNumber)
|
|
}
|
|
}
|
|
|
|
func TestItemVersionCreation(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
item := createTestItem(t, s, ws.ID, col.ID, "My Task", "Version 1")
|
|
|
|
// First content update should create a version
|
|
v2 := "Version 2"
|
|
_, err := s.UpdateItem(item.ID, models.ItemUpdate{Content: &v2})
|
|
if err != nil {
|
|
t.Fatalf("UpdateItem error: %v", err)
|
|
}
|
|
|
|
// Versions are stored in item_versions
|
|
versions, err := s.ListItemVersions(item.ID)
|
|
if err != nil {
|
|
t.Fatalf("ListItemVersions error: %v", err)
|
|
}
|
|
// Initial version from create + one from update
|
|
if len(versions) < 1 {
|
|
t.Errorf("expected at least 1 version, got %d", len(versions))
|
|
}
|
|
}
|
|
|
|
func TestWorkspaceHasAgentActivity(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Connect Banner")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Empty workspace — false.
|
|
has, err := s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity: %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false on empty workspace")
|
|
}
|
|
|
|
// Non-agent items (web, skill) shouldn't flip it on.
|
|
for _, src := range []string{"web", "skill"} {
|
|
_, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: fmt.Sprintf("From %s", src),
|
|
Fields: `{"status":"open"}`,
|
|
Source: src,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create %s item: %v", src, err)
|
|
}
|
|
}
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity (non-agent): %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false when only web/skill items exist")
|
|
}
|
|
|
|
// One CLI item — true.
|
|
cliItem, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "From CLI",
|
|
Fields: `{"status":"open"}`,
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create cli item: %v", err)
|
|
}
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity (with cli): %v", err)
|
|
}
|
|
if !has {
|
|
t.Fatal("expected true after a cli-sourced item exists")
|
|
}
|
|
|
|
// Soft-deleting the only CLI item should flip it back off.
|
|
if err := s.DeleteItem(cliItem.ID); err != nil {
|
|
t.Fatalf("delete cli item: %v", err)
|
|
}
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity (after delete): %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false after the only cli item was deleted")
|
|
}
|
|
|
|
// An item with source='mcp' (reserved for future MCP-distinct
|
|
// attribution — not currently emitted by any code path; today MCP
|
|
// activity persists as source='cli' per dispatch_http_test.go) should
|
|
// also flip the signal on, so the query stays correct if attribution
|
|
// is later split.
|
|
mcpItem, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: "From MCP",
|
|
Fields: `{"status":"open"}`,
|
|
Source: "mcp",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create mcp item: %v", err)
|
|
}
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity (with mcp): %v", err)
|
|
}
|
|
if !has {
|
|
t.Fatal("expected true after a mcp-sourced item exists")
|
|
}
|
|
// Clean up so the workspace-isolation case below reflects only the
|
|
// other-ws CLI item it inserts.
|
|
if err := s.DeleteItem(mcpItem.ID); err != nil {
|
|
t.Fatalf("delete mcp item: %v", err)
|
|
}
|
|
|
|
// Workspace isolation — a cli item in another workspace must not leak.
|
|
otherWS := createTestWorkspace(t, s, "Other")
|
|
otherCol := createTestCollection(t, s, otherWS.ID, "Tasks")
|
|
if _, err := s.CreateItem(otherWS.ID, otherCol.ID, models.ItemCreate{
|
|
Title: "Other CLI",
|
|
Fields: `{"status":"open"}`,
|
|
Source: "cli",
|
|
}); err != nil {
|
|
t.Fatalf("create other-ws cli item: %v", err)
|
|
}
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasAgentActivity (after other-ws cli): %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("a cli item in a different workspace must not flip ours on")
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceHasAgentActivityVisibility covers the visibility filter
|
|
// so a guest with restricted access can't infer the existence of
|
|
// agent-sourced items in collections they don't have visibility into.
|
|
// Codex flagged this as a P2 leak during PR #284 review — without
|
|
// filtering, has_agent_activity reflected the whole workspace regardless
|
|
// of caller visibility.
|
|
func TestWorkspaceHasAgentActivityVisibility(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Visibility")
|
|
visibleColl := createTestCollection(t, s, ws.ID, "Visible")
|
|
hiddenColl := createTestCollection(t, s, ws.ID, "Hidden")
|
|
|
|
// CLI item in the HIDDEN collection only.
|
|
hiddenCLI, err := s.CreateItem(ws.ID, hiddenColl.ID, models.ItemCreate{
|
|
Title: "Hidden CLI",
|
|
Fields: `{"status":"open"}`,
|
|
Source: "cli",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create hidden cli item: %v", err)
|
|
}
|
|
|
|
// Web item in the VISIBLE collection (so the visible set isn't empty).
|
|
if _, err := s.CreateItem(ws.ID, visibleColl.ID, models.ItemCreate{
|
|
Title: "Visible Web",
|
|
Fields: `{"status":"open"}`,
|
|
Source: "web",
|
|
}); err != nil {
|
|
t.Fatalf("create visible web item: %v", err)
|
|
}
|
|
|
|
// Unfiltered (full-visibility caller) sees the CLI item.
|
|
has, err := s.WorkspaceHasAgentActivity(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("unfiltered: %v", err)
|
|
}
|
|
if !has {
|
|
t.Fatal("unfiltered call must see the hidden CLI item")
|
|
}
|
|
|
|
// Caller with only the VISIBLE collection in scope must NOT see the
|
|
// CLI item that lives in a hidden collection.
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, []string{visibleColl.ID}, nil)
|
|
if err != nil {
|
|
t.Fatalf("visible-coll only: %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("must not surface CLI items in collections outside the caller's visible set")
|
|
}
|
|
|
|
// Item-level grant on the hidden CLI item should expose it via the
|
|
// guest-item path even when the collection isn't in scope.
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, []string{visibleColl.ID}, []string{hiddenCLI.ID})
|
|
if err != nil {
|
|
t.Fatalf("with item grant: %v", err)
|
|
}
|
|
if !has {
|
|
t.Fatal("an explicit item grant for the CLI item must surface it")
|
|
}
|
|
|
|
// Non-nil empty collectionIDs + no item grants = no visibility =
|
|
// short-circuit false (matches ListItems' early-exit semantics).
|
|
has, err = s.WorkspaceHasAgentActivity(ws.ID, []string{}, nil)
|
|
if err != nil {
|
|
t.Fatalf("empty visibility: %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("an empty visibility set must short-circuit to false")
|
|
}
|
|
}
|
|
|
|
// TestGuestVisibleResourcesIncludeDeleted_SurfacesTombstones is the
|
|
// delta-sync correctness test Codex round 1 [P1] on TASK-1354 asked
|
|
// for: when a guest's only access to an item is via an item-level
|
|
// grant, soft-deleting that item must NOT make the grant ID
|
|
// disappear from the delta-sync lookup. Otherwise /items-changes
|
|
// can never emit the `deleted:true` tombstone and the client
|
|
// keeps the stale row forever.
|
|
//
|
|
// We compare both helpers side by side so a future regression on
|
|
// either path fails this test loudly.
|
|
func TestGuestVisibleResourcesIncludeDeleted_SurfacesTombstones(t *testing.T) {
|
|
s := testStore(t)
|
|
user := createTestUser(t, s, "guest@example.com", "Guest", "password123")
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
// We deliberately DON'T call AddWorkspaceMember here — the
|
|
// helper under test (GuestVisibleResources / *IncludeDeleted)
|
|
// just looks up grants for the given user ID and is agnostic
|
|
// to workspace membership. The server-side role gating
|
|
// (workspaceRole(r) == "guest") happens in
|
|
// guestResourceFilter, which delegates to these helpers.
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Two items: one stays live, one gets soft-deleted. The guest
|
|
// has an item-level grant on each.
|
|
live := createTestItem(t, s, ws.ID, col.ID, "Live", "")
|
|
doomed := createTestItem(t, s, ws.ID, col.ID, "Doomed", "")
|
|
if _, err := s.CreateItemGrant(ws.ID, live.ID, user.ID, "viewer", user.ID); err != nil {
|
|
t.Fatalf("grant live: %v", err)
|
|
}
|
|
if _, err := s.CreateItemGrant(ws.ID, doomed.ID, user.ID, "viewer", user.ID); err != nil {
|
|
t.Fatalf("grant doomed: %v", err)
|
|
}
|
|
if err := s.DeleteItem(doomed.ID); err != nil {
|
|
t.Fatalf("delete doomed: %v", err)
|
|
}
|
|
|
|
// Live variant — filters soft-deleted items. The guest should
|
|
// only see `live.ID`.
|
|
_, liveItemIDs, err := s.GuestVisibleResources(ws.ID, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("GuestVisibleResources: %v", err)
|
|
}
|
|
if got := stringInSlice(liveItemIDs, live.ID); !got {
|
|
t.Errorf("live variant: missing live item ID %q (got %v)", live.ID, liveItemIDs)
|
|
}
|
|
if stringInSlice(liveItemIDs, doomed.ID) {
|
|
t.Errorf("live variant: should NOT include soft-deleted item, got %v", liveItemIDs)
|
|
}
|
|
|
|
// Delta-sync variant — includes soft-deleted items. The guest
|
|
// should see BOTH IDs so the tombstone can flow through
|
|
// /items-changes.
|
|
_, deltaItemIDs, err := s.GuestVisibleResourcesIncludeDeleted(ws.ID, user.ID)
|
|
if err != nil {
|
|
t.Fatalf("GuestVisibleResourcesIncludeDeleted: %v", err)
|
|
}
|
|
if !stringInSlice(deltaItemIDs, live.ID) {
|
|
t.Errorf("delta variant: missing live item ID %q (got %v)", live.ID, deltaItemIDs)
|
|
}
|
|
if !stringInSlice(deltaItemIDs, doomed.ID) {
|
|
t.Errorf("delta variant: MUST include soft-deleted item so client can drop it; got %v", deltaItemIDs)
|
|
}
|
|
}
|
|
|
|
// stringInSlice is a tiny test helper — `slices.Contains` would do
|
|
// but keeping the test free of stdlib generics ergonomics so it
|
|
// reads cleanly with the rest of the file.
|
|
func stringInSlice(xs []string, want string) bool {
|
|
for _, x := range xs {
|
|
if x == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// TestMigrateItemFieldValues_PerRowUniqueSeq guards the
|
|
// /items-changes pagination contract on bulk rewrites (Codex
|
|
// review of TASK-1354 round 2 [P1]). Before the fix, the bulk
|
|
// UPDATE assigned every affected row the same `MAX(seq)+1`. A
|
|
// /items-changes?limit=N poll that cut through that equal-seq
|
|
// group would advance the cursor to the shared seq, and the
|
|
// next `seq > cursor` poll would silently skip the rest of the
|
|
// group.
|
|
//
|
|
// The current implementation issues per-row UPDATEs inside the
|
|
// migration transaction so every row ends up with a strictly
|
|
// unique seq.
|
|
func TestMigrateItemFieldValues_PerRowUniqueSeq(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Five items, all in the same select-option bucket so the
|
|
// rename touches every row in a single migration step.
|
|
const n = 5
|
|
for i := 0; i < n; i++ {
|
|
_, err := s.CreateItem(ws.ID, col.ID, models.ItemCreate{
|
|
Title: fmt.Sprintf("row-%d", i),
|
|
Fields: `{"status":"open"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create item %d: %v", i, err)
|
|
}
|
|
}
|
|
|
|
affected, err := s.MigrateItemFieldValues(col.ID, []models.FieldMigration{
|
|
{Field: "status", RenameOptions: map[string]string{"open": "available"}},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if affected != int64(n) {
|
|
t.Fatalf("expected %d rows affected, got %d", n, affected)
|
|
}
|
|
|
|
items, err := s.ListItems(ws.ID, models.ItemListParams{CollectionSlug: col.Slug})
|
|
if err != nil {
|
|
t.Fatalf("list items: %v", err)
|
|
}
|
|
if len(items) != n {
|
|
t.Fatalf("expected %d items in list, got %d", n, len(items))
|
|
}
|
|
|
|
// Every row must have a unique seq.
|
|
seen := make(map[int64]string, n)
|
|
for _, it := range items {
|
|
if prev, dup := seen[it.Seq]; dup {
|
|
t.Fatalf("duplicate seq=%d shared by items %q and %q (bulk migrate must assign per-row unique seqs for /items-changes pagination)", it.Seq, prev, it.ID)
|
|
}
|
|
seen[it.Seq] = it.ID
|
|
}
|
|
}
|
|
|
|
// TestItemSeqMonotonic verifies that the workspace-scoped `seq` column
|
|
// is stamped strictly monotonically across every mutation
|
|
// (create / update / soft-delete / restore). This is the cursor
|
|
// mechanic the local-first read model relies on for delta sync
|
|
// (PLAN-1343 / TASK-1352).
|
|
func TestItemSeqMonotonic(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// CREATE: three rows, each must have seq strictly greater than
|
|
// the previous one.
|
|
a := createTestItem(t, s, ws.ID, col.ID, "A", "alpha")
|
|
b := createTestItem(t, s, ws.ID, col.ID, "B", "beta")
|
|
c := createTestItem(t, s, ws.ID, col.ID, "C", "gamma")
|
|
|
|
if a.Seq <= 0 {
|
|
t.Fatalf("create: a.Seq = %d; want > 0", a.Seq)
|
|
}
|
|
if b.Seq <= a.Seq {
|
|
t.Fatalf("create: b.Seq = %d; want > a.Seq = %d", b.Seq, a.Seq)
|
|
}
|
|
if c.Seq <= b.Seq {
|
|
t.Fatalf("create: c.Seq = %d; want > b.Seq = %d", c.Seq, b.Seq)
|
|
}
|
|
|
|
// UPDATE: bumps seq above every prior value.
|
|
newTitle := "A-updated"
|
|
aUpd, err := s.UpdateItem(a.ID, models.ItemUpdate{Title: &newTitle})
|
|
if err != nil {
|
|
t.Fatalf("update item: %v", err)
|
|
}
|
|
if aUpd.Seq <= c.Seq {
|
|
t.Fatalf("update: aUpd.Seq = %d; want > c.Seq = %d", aUpd.Seq, c.Seq)
|
|
}
|
|
|
|
// SOFT-DELETE: tombstone gets a fresh seq above the post-update
|
|
// floor so delta-sync clients see the deletion.
|
|
if err := s.DeleteItem(b.ID); err != nil {
|
|
t.Fatalf("delete item: %v", err)
|
|
}
|
|
bDel, err := s.GetItemIncludeDeleted(b.ID)
|
|
if err != nil || bDel == nil {
|
|
t.Fatalf("get deleted item: %v", err)
|
|
}
|
|
if bDel.DeletedAt == nil {
|
|
t.Fatal("deleted item is missing deleted_at")
|
|
}
|
|
if bDel.Seq <= aUpd.Seq {
|
|
t.Fatalf("delete: bDel.Seq = %d; want > aUpd.Seq = %d", bDel.Seq, aUpd.Seq)
|
|
}
|
|
|
|
// RESTORE: un-archive bumps seq again.
|
|
bRestored, err := s.RestoreItem(b.ID)
|
|
if err != nil {
|
|
t.Fatalf("restore item: %v", err)
|
|
}
|
|
if bRestored.DeletedAt != nil {
|
|
t.Fatal("restored item still has deleted_at")
|
|
}
|
|
if bRestored.Seq <= bDel.Seq {
|
|
t.Fatalf("restore: bRestored.Seq = %d; want > bDel.Seq = %d", bRestored.Seq, bDel.Seq)
|
|
}
|
|
|
|
// Sanity-check: another update on c bumps past the restore.
|
|
newCContent := "gamma-prime"
|
|
cUpd, err := s.UpdateItem(c.ID, models.ItemUpdate{Content: &newCContent})
|
|
if err != nil {
|
|
t.Fatalf("second update: %v", err)
|
|
}
|
|
if cUpd.Seq <= bRestored.Seq {
|
|
t.Fatalf("update after restore: cUpd.Seq = %d; want > bRestored.Seq = %d", cUpd.Seq, bRestored.Seq)
|
|
}
|
|
}
|
|
|
|
// TestItemSeqWorkspaceScoped verifies that the seq counter is
|
|
// scoped per workspace: a busy workspace's seq range does not
|
|
// affect another workspace's monotonic floor (DOC-1342 design
|
|
// decision #1).
|
|
func TestItemSeqWorkspaceScoped(t *testing.T) {
|
|
s := testStore(t)
|
|
ws1 := createTestWorkspace(t, s, "WS1")
|
|
ws2 := createTestWorkspace(t, s, "WS2")
|
|
col1 := createTestCollection(t, s, ws1.ID, "Tasks")
|
|
col2 := createTestCollection(t, s, ws2.ID, "Tasks")
|
|
|
|
// Create three items in ws1 to bump its seq.
|
|
createTestItem(t, s, ws1.ID, col1.ID, "ws1-a", "")
|
|
createTestItem(t, s, ws1.ID, col1.ID, "ws1-b", "")
|
|
ws1Last := createTestItem(t, s, ws1.ID, col1.ID, "ws1-c", "")
|
|
|
|
// ws2's first item must start at seq=1, NOT ws1Last.Seq+1.
|
|
ws2First := createTestItem(t, s, ws2.ID, col2.ID, "ws2-a", "")
|
|
if ws2First.Seq != 1 {
|
|
t.Fatalf("ws2 first item Seq = %d; want 1 (per-workspace counter)", ws2First.Seq)
|
|
}
|
|
if ws2First.Seq >= ws1Last.Seq {
|
|
t.Fatalf("ws2.Seq (%d) >= ws1.Seq (%d) — ws2 is following ws1's range, not its own", ws2First.Seq, ws1Last.Seq)
|
|
}
|
|
}
|
|
|
|
// TestItemSeqBackfillNonZero verifies that the migration backfill
|
|
// assigned non-zero seq values to pre-existing rows. We can't easily
|
|
// simulate "pre-migration" rows in tests (the migration runs once on
|
|
// store init), so this test inserts a fresh batch and confirms each
|
|
// row's seq is non-zero and unique within the workspace.
|
|
func TestItemSeqBackfillNonZero(t *testing.T) {
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "Test")
|
|
col := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
const n = 5
|
|
seqs := make(map[int64]bool, n)
|
|
for i := 0; i < n; i++ {
|
|
item := createTestItem(t, s, ws.ID, col.ID, fmt.Sprintf("item-%d", i), "")
|
|
if item.Seq == 0 {
|
|
t.Fatalf("row %d: Seq = 0; expected non-zero", i)
|
|
}
|
|
if seqs[item.Seq] {
|
|
t.Fatalf("row %d: Seq = %d collides with prior row in same workspace", i, item.Seq)
|
|
}
|
|
seqs[item.Seq] = true
|
|
}
|
|
}
|