mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-21 01:53:33 +00:00
e5eae5e94e
* feat(web): persistent dismissible CLI-connect banner on workspace pages (TASK-862)
Final web piece of the web-first onboarding on-ramp from PLAN-859 / IDEA-750.
A slim banner now nudges users to connect their workspace to the CLI on
every workspace page, until they either dismiss it or actually do it.
Server:
- New store method WorkspaceHasCLISource(workspaceID) — backed by
EXISTS(... WHERE source='cli' AND deleted_at IS NULL), so it's a
cheap O(1) check that short-circuits on the first match.
- Dashboard payload (GET /workspaces/{ws}/dashboard) gains
HasCLISource bool (json: has_cli_source).
- Unit tests cover empty workspace, web/skill items don't trip it,
one cli item flips it on, soft-delete flips it back off, and
cross-workspace isolation.
Web:
- New <ConnectBanner> Svelte 5 component
(web/src/lib/components/ConnectBanner.svelte). Self-contained:
reads dismissed state from localStorage, fetches has_cli_source
itself, mounts <ConnectWorkspaceModal> internally. Two split
$effect blocks per CONVE-606 — one for the localStorage sync, one
for the dashboard fetch — so a workspace change doesn't entangle
the two reactive lifecycles.
- Banner is hidden while loading (hasCliSource === null) to avoid a
flash-then-auto-hide on workspaces that already have CLI items.
- Storage key pad-cli-banner-dismissed-${wsSlug} matches the existing
onboarding-dismissed pattern. Per-browser only; TODO comment in
source about backing it with a workspace_user_state row if cross-
device persistence is wanted later.
- Mounted in web/src/routes/[username]/[workspace]/+layout.svelte
above {@render children()} so it appears on every workspace page
(dashboard, collection lists, item detail, search, activity, etc.)
and NOT on console/auth pages (the layout is workspace-scoped).
- DashboardResponse type in web/src/lib/types/index.ts gains
has_cli_source: boolean.
Smoke-tested against the running server: the field is live in the
dashboard payload and reflects reality (this workspace returns
has_cli_source: true since it has many CLI-sourced items, so the
banner is correctly auto-hidden here).
Test plan:
- go build ./... && go test ./... — all green (incl. new
TestWorkspaceHasCLISource with 5 sub-cases).
- cd web && npm run build — clean.
- make install — clean, server restarted.
- Svelte MCP autofixer ran on ConnectBanner.svelte — no issues.
Parent: PLAN-859. Driving idea: IDEA-750.
* fix(web/connect-banner): stale-response guard + refetch on modal close (Codex round 1)
Two findings from Codex review on PR #284:
1. Stale-response race: rapid workspace switches could let a slow
dashboard fetch from workspace A overwrite hasCliSource for
workspace B after the user navigated. Capture the requested slug
at fetch time, ignore the response if wsSlug has changed since.
2. Auto-hide didn't work in-session: if a user opened the banner
modal, copied the command, ran it elsewhere, and closed the modal,
the banner stayed visible because hasCliSource was stale. Refetch
when the modal transitions from open → closed (the natural moment
the user has just connected). Uses $effect.pre with a tracked
previous value, matching the transition pattern in ShareDialog.
The 'someone ran the CLI from another terminal without ever opening
the modal' edge case is left for a follow-up — would require SSE
item-created subscription, which is heavier than this PR's scope.
* fix(server/items): persist source from auth context on create (Codex round 2)
Codex caught an architectural bug while reviewing the TASK-862 banner
work: items created via the CLI were persisting with source='web'
(the column default) instead of 'cli', because handleCreateItem decoded
ItemCreate from the body — which the CLI doesn't set Source on — and
only consulted actorFromRequest AFTER persisting (for SSE / activity
log emission). Result: TASK-862's has_cli_source dashboard signal
would never flip on for normal CLI usage, so the connect-CLI banner
would never auto-hide for users who actually wired up the CLI.
Fix: in handleCreateItem, backfill input.Source from actorFromRequest
before calling store.CreateItem, but only when the client didn't
explicitly set it (so agents marking themselves as 'skill' still
pass through unchanged).
Test: TestCreateItemSourcePersistedFromAuth covers all three branches
- bearer Authorization header → source=cli (uses bootstrap + a real
session token in the header since the auth middleware validates
token format and rejects fake values with 401 before the handler
runs)
- cookie session, no Authorization → source=web
- explicit source in body wins over auth-derived (e.g. 'skill')
* fix(web/connect-banner): seq counter for same-workspace race (Codex round 3)
Round 3 caught a same-workspace race the slug guard didn't cover: an
in-flight workspace-change fetch that resolves AFTER the modal-close
refetch could overwrite the newer 'true' with the older 'false',
making the banner reappear after the user actually wired up the CLI.
Add a monotonic fetchSeq counter — captured at call time, rechecked
before applying the response. Only the LATEST request's result wins,
regardless of arrival order. The slug guard stays as a second-layer
defense for cross-workspace races.
* fix(web/connect-banner): guard banner keydown to currentTarget (Codex round 4)
Round 4 caught a keyboard-event bubble: pressing Enter or Space on
the dismiss X button also fired the banner-level keydown handler,
so the user would dismiss AND open the modal in one stroke.
Guard the parent handler with `e.target !== e.currentTarget` so it
only reacts to keydown that originated on the banner itself. Tabbing
to the dismiss button + Enter now ONLY dismisses.
* fix(store): visibility-filter has_cli_source query (Codex round 5)
Round 5 caught a P2 information leak: WorkspaceHasCLISource scanned
the entire workspace regardless of caller visibility, so a guest
with grants only on web-sourced items could still see has_cli_source
return true (revealing that CLI items exist somewhere they can't see).
That also produced wrong UX — the banner could auto-hide for guests
who couldn't actually use the CLI.
Extend the query to take optional collectionIDs/itemIDs filters
matching the dashboard's existing visibility model: an item counts
when its collection is in collectionIDs OR its id is in itemIDs
(union — guest item-level grants can expose items in otherwise-
hidden collections). Mirrors ListItems' filtering pattern incl. the
"non-nil empty CollectionIDs = no visibility = short-circuit false"
semantics.
Handler now passes dashCollIDs and dashItemIDs to match the rest of
the dashboard payload's filtering. New TestWorkspaceHasCLISourceVisibility
covers the four cases: unfiltered sees all, visible-coll-only hides
CLI items in hidden collections, item-level grant surfaces a hidden
CLI item, and empty visibility short-circuits to false.
2371 lines
76 KiB
Go
2371 lines
76 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"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")
|
|
}
|
|
}
|
|
|
|
// 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))
|
|
}
|
|
}
|
|
|
|
// --- 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 TestWorkspaceHasCLISource(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.WorkspaceHasCLISource(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasCLISource: %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false on empty workspace")
|
|
}
|
|
|
|
// Non-cli items 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.WorkspaceHasCLISource(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasCLISource (non-cli): %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.WorkspaceHasCLISource(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasCLISource (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.WorkspaceHasCLISource(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasCLISource (after delete): %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("expected false after the only cli item was deleted")
|
|
}
|
|
|
|
// 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.WorkspaceHasCLISource(ws.ID, nil, nil)
|
|
if err != nil {
|
|
t.Fatalf("WorkspaceHasCLISource (after other-ws cli): %v", err)
|
|
}
|
|
if has {
|
|
t.Fatal("a cli item in a different workspace must not flip ours on")
|
|
}
|
|
}
|
|
|
|
// TestWorkspaceHasCLISourceVisibility covers the visibility filter so a
|
|
// guest with restricted access can't infer the existence of CLI items in
|
|
// collections they don't have visibility into. Codex flagged this as a
|
|
// P2 leak during PR #284 review — without filtering, has_cli_source
|
|
// reflected the whole workspace regardless of caller visibility.
|
|
func TestWorkspaceHasCLISourceVisibility(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.WorkspaceHasCLISource(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.WorkspaceHasCLISource(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.WorkspaceHasCLISource(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.WorkspaceHasCLISource(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")
|
|
}
|
|
}
|