Files
pad/internal/store/store_test.go
T
xarmian 20544fdd44 perf(test): build the SQLite migration chain once per test binary (IDEA-1914) (#788)
* perf(test): build the SQLite migration chain once per test binary (IDEA-1914)

internal/server's -race suite spent ~30 minutes replaying all 69
migrations + 3 backfills per test (~2.7s each, 622 store-backed tests,
BUG-1913). Add internal/store/storetest, which runs the full migration
chain once into a checkpointed, sidecar-free template DB (sync.Once)
and hands every test a plain file copy opened via store.New. Wire it
into internal/server's testServer/testServer_Stop_DrainsRateLimiterCleanup
and internal/store's own testStore (duplicated inline there — an
import cycle rules out sharing storetest with store's white-box
tests). Postgres-mode tests are untouched.

internal/server -race: 1819s -> 183s.

* fix(test): plug template-dir leak and Cleanup race in storetest fixture

Codex round 2 on IDEA-1914: buildTemplate/buildSQLiteTemplate left the
MkdirTemp'd template dir on disk if store.New/checkpoint/journal_mode
failed after mkdir succeeded — now removed via a disarm-on-success
defer in both mirrored copies. Also guard Cleanup()/removeSQLiteTemplate
against racing an in-flight build+copy with a sync.RWMutex (read-locked
across build+copy, write-locked for removal) in both places.
2026-07-03 10:15:49 -04:00

1775 lines
56 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package store
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/google/uuid"
)
// testStore creates a Store for testing. When PAD_TEST_POSTGRES_URL is set,
// it creates an isolated PostgreSQL database; otherwise it falls back to a
// fast SQLite fixture (see testStoreSQLite). Every test gets its own
// database so tests never interfere with each other.
func testStore(t *testing.T) *Store {
t.Helper()
if pgURL := os.Getenv("PAD_TEST_POSTGRES_URL"); pgURL != "" {
return testStorePostgres(t, pgURL)
}
return testStoreSQLite(t)
}
// testStoreSQLite mirrors internal/store/storetest's NewSQLite (IDEA-1914):
// the full migration chain runs at most once per test binary (a
// sync.Once-guarded template DB), and every call gets a plain file copy
// of it, opened via New like any other test store.
//
// This logic is DUPLICATED from internal/store/storetest/storetest.go
// rather than shared, because internal/store's own white-box tests
// (package store, this file) cannot import a package that itself imports
// store — Go rejects that as an import cycle ("import cycle not allowed
// in test", verified). KEEP THIS FUNCTION AND storetest.go's
// buildTemplate/NewSQLite IN SYNC — see that file's package doc for the
// full rationale.
var (
// sqliteTemplateMu guards sqliteTemplatePath against removeSQLiteTemplate
// racing an in-flight build/copy — mirrors storetest.go's templateMu.
sqliteTemplateMu sync.RWMutex
sqliteTemplateOnce sync.Once
sqliteTemplatePath string
sqliteTemplateErr error
)
func testStoreSQLite(t *testing.T) *Store {
t.Helper()
dst := filepath.Join(t.TempDir(), "test.db")
if err := lockedCopyFromSQLiteTemplate(dst); err != nil {
t.Fatalf("%v", err)
}
s, err := New(dst)
if err != nil {
t.Fatalf("failed to create store: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
// lockedCopyFromSQLiteTemplate builds the template (once) and copies it
// to dst, holding sqliteTemplateMu for read across both steps so
// removeSQLiteTemplate (which takes the write lock) can't remove the
// template out from under an in-flight build or copy. Mirrors
// storetest.go's lockedCopyFromTemplate.
func lockedCopyFromSQLiteTemplate(dst string) error {
sqliteTemplateMu.RLock()
defer sqliteTemplateMu.RUnlock()
sqliteTemplateOnce.Do(func() {
sqliteTemplatePath, sqliteTemplateErr = buildSQLiteTemplate()
})
if sqliteTemplateErr != nil {
return fmt.Errorf("build template db: %w", sqliteTemplateErr)
}
if err := copyTemplateFile(sqliteTemplatePath, dst); err != nil {
return fmt.Errorf("copy template db: %w", err)
}
return nil
}
// buildSQLiteTemplate runs the full migration chain once into a
// process-wide temp file. The template is checkpointed and left in
// DELETE journal mode (no -wal/-shm sidecars), so testStoreSQLite's copy
// is a single-file operation.
//
// The template dir is removed on any error path (MkdirTemp succeeded but
// a later step failed) so a failed build doesn't leak a /tmp directory;
// mirrors storetest.go's buildTemplate.
func buildSQLiteTemplate() (string, error) {
dir, err := os.MkdirTemp("", "pad-store-template-*")
if err != nil {
return "", fmt.Errorf("mkdir template dir: %w", err)
}
ok := false
defer func() {
if !ok {
_ = os.RemoveAll(dir)
}
}()
path := filepath.Join(dir, "template.db")
s, err := New(path)
if err != nil {
return "", fmt.Errorf("build template store: %w", err)
}
defer s.Close()
if _, err := s.DB().Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
return "", fmt.Errorf("checkpoint template: %w", err)
}
if _, err := s.DB().Exec("PRAGMA journal_mode=DELETE"); err != nil {
return "", fmt.Errorf("set template journal_mode=DELETE: %w", err)
}
ok = true
return path, nil
}
func copyTemplateFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return fmt.Errorf("open %s: %w", src, err)
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return fmt.Errorf("create %s: %w", dst, err)
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
return fmt.Errorf("copy %s -> %s: %w", src, dst, err)
}
return out.Close()
}
// removeSQLiteTemplate deletes the template directory built by
// buildSQLiteTemplate, if any. Called from TestMain after m.Run().
func removeSQLiteTemplate() {
sqliteTemplateMu.Lock()
defer sqliteTemplateMu.Unlock()
if sqliteTemplatePath != "" {
_ = os.RemoveAll(filepath.Dir(sqliteTemplatePath))
}
}
// testStorePostgres creates an isolated test database on the PostgreSQL server.
// It connects to the base URL, creates a randomly-named database, runs
// migrations, and drops the database when the test finishes.
func testStorePostgres(t *testing.T, baseURL string) *Store {
t.Helper()
// Generate a unique database name for this test.
dbName := "pad_test_" + uuid.New().String()[:8]
// Connect to the default "pad" database to issue CREATE/DROP DATABASE.
adminStore, err := newPostgresConn(baseURL)
if err != nil {
t.Fatalf("connect to postgres for admin: %v", err)
}
// CREATE DATABASE cannot run inside a transaction.
if _, err := adminStore.db.Exec(fmt.Sprintf("CREATE DATABASE %s", dbName)); err != nil {
adminStore.Close()
t.Fatalf("create test database %s: %v", dbName, err)
}
adminStore.Close()
// Build the connection string for the new database.
testURL := replaceDBName(baseURL, dbName)
s, err := NewPostgres(testURL)
if err != nil {
// Clean up the database we just created.
if admin2, err2 := newPostgresConn(baseURL); err2 == nil {
admin2.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s", dbName))
admin2.Close()
}
t.Fatalf("open test postgres store: %v", err)
}
t.Cleanup(func() {
s.Close()
// Drop the test database.
if admin, err := newPostgresConn(baseURL); err == nil {
admin.db.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS %s WITH (FORCE)", dbName))
admin.Close()
}
})
return s
}
// newPostgresConn opens a raw postgres connection (no migrations).
func newPostgresConn(connStr string) (*Store, error) {
db, err := openPostgresDB(connStr)
if err != nil {
return nil, err
}
return &Store{db: db, dialect: &postgresDialect{}}, nil
}
// replaceDBName swaps the database name in a postgres:// URL.
// e.g. "postgres://pad:pad@localhost:5432/pad?sslmode=disable"
// becomes "postgres://pad:pad@localhost:5432/newdb?sslmode=disable"
func replaceDBName(connStr, newDB string) string {
// Split off any '?' query string so we only operate on the path.
query := ""
base := connStr
if qIdx := indexOf(connStr, '?'); qIdx >= 0 {
query = connStr[qIdx:]
base = connStr[:qIdx]
}
// Find the last '/' in the base to replace the db name.
if lastSlash := lastIndexOf(base, '/'); lastSlash >= 0 {
return base[:lastSlash+1] + newDB + query
}
return connStr
}
func indexOf(s string, c byte) int {
for i := 0; i < len(s); i++ {
if s[i] == c {
return i
}
}
return -1
}
func lastIndexOf(s string, c byte) int {
for i := len(s) - 1; i >= 0; i-- {
if s[i] == c {
return i
}
}
return -1
}
func createTestWorkspace(t *testing.T, s *Store, name string) *models.Workspace {
t.Helper()
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: name})
if err != nil {
t.Fatalf("failed to create workspace: %v", err)
}
return ws
}
func createTestDoc(t *testing.T, s *Store, workspaceID, title, content string) *models.Document {
t.Helper()
doc, err := s.CreateDocument(workspaceID, models.DocumentCreate{
Title: title,
Content: content,
DocType: "notes",
Status: "active",
})
if err != nil {
t.Fatalf("failed to create document: %v", err)
}
return doc
}
func schemaFieldKeys(t *testing.T, schemaJSON string) []string {
t.Helper()
var schema models.CollectionSchema
if err := json.Unmarshal([]byte(schemaJSON), &schema); err != nil {
t.Fatalf("unmarshal collection schema: %v", err)
}
keys := make([]string, 0, len(schema.Fields))
for _, field := range schema.Fields {
keys = append(keys, field.Key)
}
return keys
}
// TestSQLiteConcurrentWritersNoBusy guards against the BUG-748-followup
// regression where Go's default deferred-mode transactions returned
// SQLITE_BUSY immediately under contention because lock-upgrade does not
// honor busy_timeout. With `_txlock=immediate` set in the DSN, every
// `db.Begin()` issues `BEGIN IMMEDIATE`, so the write lock is acquired
// up-front and concurrent writers wait via busy_timeout instead of
// failing fast.
//
// The benchmark in `bench_concurrent_test.go` quantifies throughput;
// this test specifically asserts ZERO errors under a concurrent-update
// workload that previously produced multiple `SQLITE_BUSY` returns.
func TestSQLiteConcurrentWritersNoBusy(t *testing.T) {
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
t.Skip("SQLite-specific concurrency test; postgres has its own MVCC semantics")
}
dir := t.TempDir()
s, err := New(filepath.Join(dir, "test.db"))
if err != nil {
t.Fatalf("create store: %v", err)
}
defer s.Close()
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "concurrency"})
if err != nil {
t.Fatalf("create workspace: %v", err)
}
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Tasks",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("create collection: %v", err)
}
// 25 goroutines × 5 ops each — these all open transactions
// (tryCreateItem in items.go uses db.Begin), and previously raced
// for the write-lock on upgrade.
//
// Synchronization: a two-WaitGroup pattern (`ready` to confirm each
// worker has reached the gate, then `release` to free them all at
// once). This is NOT a mathematically exact barrier — there's a
// small unobservable gap between `ready.Done()` and `release.Wait()`
// in each worker, and a worker descheduled in that gap could miss
// the simultaneous release. In practice the gap is sub-microsecond,
// vanishingly small compared to the millisecond-scale contention
// window the test is probing, and the multiple-ops-per-worker
// structure means even a slightly-late worker still produces enough
// concurrent BEGIN IMMEDIATE attempts to exercise the race.
//
// Empirical check (2026-04-25): with `_txlock=immediate` removed
// from the DSN this test reliably FAILS — a representative run on
// a developer laptop produced ~22 errors out of 125 ops, all
// `database is locked (5) (SQLITE_BUSY)`; the exact rate is
// host- and scheduler-dependent but consistently >0. With the fix
// in place, 20 consecutive `go test -count=20` runs all pass. So
// the imprecision in the barrier doesn't impair the test's
// regression-catching ability.
const workers = 25
const opsPerWorker = 5
errCh := make(chan error, workers*opsPerWorker)
var ready sync.WaitGroup
ready.Add(workers)
var release sync.WaitGroup
release.Add(1)
var done sync.WaitGroup
for i := 0; i < workers; i++ {
idx := i
done.Add(1)
go func() {
defer done.Done()
ready.Done() // signal "I'm at the gate"
release.Wait() // park here until main thread releases
for op := 0; op < opsPerWorker; op++ {
_, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{
Title: fmt.Sprintf("concurrent-%d-%d", idx, op),
Fields: `{"status":"open"}`,
})
if err != nil {
errCh <- err
}
}
}()
}
ready.Wait() // every worker has called ready.Done() (best-effort gate)
release.Done() // release them all at once
done.Wait()
close(errCh)
var errs []error
for e := range errCh {
errs = append(errs, e)
}
if len(errs) > 0 {
t.Errorf("expected zero errors under %d concurrent writers × %d ops, got %d:", workers, opsPerWorker, len(errs))
for _, e := range errs {
t.Errorf(" - %v", e)
}
}
}
func TestNewStore(t *testing.T) {
if os.Getenv("PAD_TEST_POSTGRES_URL") != "" {
t.Skip("skipping SQLite-specific test when running against PostgreSQL")
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "test.db")
s, err := New(dbPath)
if err != nil {
t.Fatalf("New() error: %v", err)
}
defer s.Close()
// DB file should exist
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
t.Error("database file was not created")
}
}
func TestNewStorePostgres(t *testing.T) {
pgURL := os.Getenv("PAD_TEST_POSTGRES_URL")
if pgURL == "" {
t.Skip("PAD_TEST_POSTGRES_URL not set, skipping PostgreSQL test")
}
s := testStorePostgres(t, pgURL)
// Verify we can ping and the dialect is correct.
if err := s.Ping(); err != nil {
t.Fatalf("Ping() error: %v", err)
}
if s.dialect.Driver() != DriverPostgres {
t.Errorf("expected DriverPostgres, got %v", s.dialect.Driver())
}
}
// --- Workspace Tests ---
func TestWorkspaceCRUD(t *testing.T) {
s := testStore(t)
// Create
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "My App"})
if err != nil {
t.Fatalf("CreateWorkspace error: %v", err)
}
if ws.Name != "My App" {
t.Errorf("expected name 'My App', got %q", ws.Name)
}
if ws.Slug != "my-app" {
t.Errorf("expected slug 'my-app', got %q", ws.Slug)
}
// List
list, err := s.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces error: %v", err)
}
if len(list) != 1 {
t.Errorf("expected 1 workspace, got %d", len(list))
}
// Get by slug
got, err := s.GetWorkspaceBySlug("my-app")
if err != nil {
t.Fatalf("GetWorkspaceBySlug error: %v", err)
}
if got == nil || got.ID != ws.ID {
t.Error("GetWorkspaceBySlug returned wrong workspace")
}
// Update
newName := "My Updated App"
updated, err := s.UpdateWorkspace("my-app", models.WorkspaceUpdate{Name: &newName})
if err != nil {
t.Fatalf("UpdateWorkspace error: %v", err)
}
if updated.Name != "My Updated App" {
t.Errorf("expected updated name, got %q", updated.Name)
}
// Delete (soft)
err = s.DeleteWorkspace("my-app")
if err != nil {
t.Fatalf("DeleteWorkspace error: %v", err)
}
// Should not appear in list
list, _ = s.ListWorkspaces()
if len(list) != 0 {
t.Error("deleted workspace still appears in list")
}
// Should not be found by slug
got, _ = s.GetWorkspaceBySlug("my-app")
if got != nil {
t.Error("deleted workspace still found by slug")
}
}
// TestListWorkspaces_UpdatedAtReflectsItemActivity is the regression
// test for BUG-1481: `pad workspace list` was showing the workspaces
// row's updated_at, which is only bumped by workspace-row mutations
// (rename, settings, members), not by item activity inside the
// workspace. After the fix, the returned UpdatedAt should be the later
// of workspaces.updated_at and MAX(items.updated_at), so the freshness
// signal answers "where is work happening?".
func TestListWorkspaces_UpdatedAtReflectsItemActivity(t *testing.T) {
s := testStore(t)
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Active"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Tasks",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection: %v", err)
}
// Capture the workspace's own row-level updated_at as a baseline.
wsRow, err := s.GetWorkspaceBySlug(ws.Slug)
if err != nil {
t.Fatalf("GetWorkspaceBySlug: %v", err)
}
baselineWS := wsRow.UpdatedAt
// Manually backdate the workspace row's updated_at and forward-date
// a new item, so the only way the freshness can read "later" is by
// surfacing the item's timestamp.
pastTS := "2020-01-01T00:00:00Z"
if _, err := s.db.Exec(s.q(`UPDATE workspaces SET updated_at = ? WHERE id = ?`), pastTS, ws.ID); err != nil {
t.Fatalf("backdate workspace: %v", err)
}
item, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{Title: "Recent work"})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
list, err := s.ListWorkspaces()
if err != nil {
t.Fatalf("ListWorkspaces: %v", err)
}
var got *models.Workspace
for i := range list {
if list[i].ID == ws.ID {
got = &list[i]
}
}
if got == nil {
t.Fatalf("workspace %s not in ListWorkspaces result", ws.ID)
}
// The returned UpdatedAt must reflect the item activity, not the
// (now ancient) workspace row mtime.
if !got.UpdatedAt.Equal(item.UpdatedAt) {
t.Errorf("ListWorkspaces UpdatedAt = %v, want item activity %v (workspace row backdated to %v, baseline was %v)",
got.UpdatedAt, item.UpdatedAt, pastTS, baselineWS)
}
if !got.UpdatedAt.After(parseTime(pastTS)) {
t.Errorf("UpdatedAt %v should be after backdated workspace row mtime %s", got.UpdatedAt, pastTS)
}
}
// TestGetUserWorkspaces_MemberFreshness covers the member path of
// BUG-1481: a workspace returned via membership in GetUserWorkspaces
// must surface item activity as its effective UpdatedAt, not just the
// workspace row mtime.
func TestGetUserWorkspaces_MemberFreshness(t *testing.T) {
s := testStore(t)
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Member-WS"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
coll, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Tasks",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection: %v", err)
}
u := createTestUser(t, s, "member@test.com", "Member", "password123")
if err := s.AddWorkspaceMember(ws.ID, u.ID, "editor"); err != nil {
t.Fatalf("AddWorkspaceMember: %v", err)
}
// Backdate the workspace row, then create an item — the freshness
// must reflect the item's timestamp, not the backdated row.
pastTS := "2020-01-01T00:00:00Z"
if _, err := s.db.Exec(s.q(`UPDATE workspaces SET updated_at = ? WHERE id = ?`), pastTS, ws.ID); err != nil {
t.Fatalf("backdate workspace: %v", err)
}
item, err := s.CreateItem(ws.ID, coll.ID, models.ItemCreate{Title: "Member work"})
if err != nil {
t.Fatalf("CreateItem: %v", err)
}
list, err := s.GetUserWorkspaces(u.ID)
if err != nil {
t.Fatalf("GetUserWorkspaces: %v", err)
}
var got *models.Workspace
for i := range list {
if list[i].ID == ws.ID {
got = &list[i]
}
}
if got == nil {
t.Fatalf("workspace %s not in GetUserWorkspaces result", ws.ID)
}
if !got.UpdatedAt.Equal(item.UpdatedAt) {
t.Errorf("GetUserWorkspaces member UpdatedAt = %v, want item activity %v",
got.UpdatedAt, item.UpdatedAt)
}
}
// TestGetUserWorkspaces_GuestFreshnessLimitedToVisibleItems covers the
// guest path of BUG-1481 AND the data-leak guardrail surfaced in
// codex review: a guest's view of a workspace's UpdatedAt must reflect
// ONLY items they can actually see (via collection_grants or
// item_grants). Activity in items they have no grant on must not leak
// through the freshness signal.
func TestGetUserWorkspaces_GuestFreshnessLimitedToVisibleItems(t *testing.T) {
s := testStore(t)
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Guest-WS"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
owner := createTestUser(t, s, "owner@test.com", "Owner", "password123")
if err := s.AddWorkspaceMember(ws.ID, owner.ID, "owner"); err != nil {
t.Fatalf("AddWorkspaceMember owner: %v", err)
}
visibleColl, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Visible",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection visible: %v", err)
}
hiddenColl, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Hidden",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection hidden: %v", err)
}
// Create the guest user and grant them access ONLY to the visible
// collection — they should never see the hidden collection's items.
guest := createTestUser(t, s, "guest@test.com", "Guest", "password123")
if _, err := s.CreateCollectionGrant(ws.ID, visibleColl.ID, guest.ID, "read", owner.ID); err != nil {
t.Fatalf("CreateCollectionGrant: %v", err)
}
// Backdate the workspace row so neither item is being shadowed by
// the row's own mtime.
pastTS := "2020-01-01T00:00:00Z"
if _, err := s.db.Exec(s.q(`UPDATE workspaces SET updated_at = ? WHERE id = ?`), pastTS, ws.ID); err != nil {
t.Fatalf("backdate workspace: %v", err)
}
// Create a visible item first, then a hidden item LATER — so if
// the freshness query ignores the grant filter, it will surface the
// later (hidden) timestamp and the test will catch the leak.
visibleItem, err := s.CreateItem(ws.ID, visibleColl.ID, models.ItemCreate{Title: "Visible work"})
if err != nil {
t.Fatalf("CreateItem visible: %v", err)
}
// Force a measurable gap; RFC3339 stores second-precision so a
// 1.1s sleep guarantees a distinct later timestamp.
time.Sleep(1100 * time.Millisecond)
hiddenItem, err := s.CreateItem(ws.ID, hiddenColl.ID, models.ItemCreate{Title: "Hidden work"})
if err != nil {
t.Fatalf("CreateItem hidden: %v", err)
}
if !hiddenItem.UpdatedAt.After(visibleItem.UpdatedAt) {
t.Fatalf("setup invariant: hidden item should be newer than visible item (visible=%v hidden=%v)",
visibleItem.UpdatedAt, hiddenItem.UpdatedAt)
}
list, err := s.GetUserWorkspaces(guest.ID)
if err != nil {
t.Fatalf("GetUserWorkspaces guest: %v", err)
}
var got *models.Workspace
for i := range list {
if list[i].ID == ws.ID {
got = &list[i]
}
}
if got == nil {
t.Fatalf("guest workspace %s not in GetUserWorkspaces result", ws.ID)
}
if !got.IsGuest {
t.Errorf("expected workspace to be flagged as guest, got IsGuest=%v", got.IsGuest)
}
// The guest's view must show the visible item's timestamp, NOT the
// newer hidden item's timestamp — that would be a freshness leak.
if !got.UpdatedAt.Equal(visibleItem.UpdatedAt) {
t.Errorf("guest UpdatedAt = %v, want visible item activity %v (leak check: hidden item = %v)",
got.UpdatedAt, visibleItem.UpdatedAt, hiddenItem.UpdatedAt)
}
if got.UpdatedAt.Equal(hiddenItem.UpdatedAt) || got.UpdatedAt.After(hiddenItem.UpdatedAt) {
t.Errorf("guest UpdatedAt %v leaks hidden-item activity %v", got.UpdatedAt, hiddenItem.UpdatedAt)
}
}
// TestGetUserWorkspaces_MemberSpecificAccessFreshnessLimitedToVisibleItems
// covers the member-with-restricted-access path of BUG-1481: a member
// with collection_access='specific' must only see freshness from
// collections they can actually access (via member_collection_access,
// system collections, collection_grants, or item_grants). Without
// scoping, the read-time MAX would leak activity timing for hidden
// collections to restricted members.
func TestGetUserWorkspaces_MemberSpecificAccessFreshnessLimitedToVisibleItems(t *testing.T) {
s := testStore(t)
ws, err := s.CreateWorkspace(models.WorkspaceCreate{Name: "Restricted-WS"})
if err != nil {
t.Fatalf("CreateWorkspace: %v", err)
}
visibleColl, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Visible",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection visible: %v", err)
}
hiddenColl, err := s.CreateCollection(ws.ID, models.CollectionCreate{
Name: "Hidden",
Schema: `{"fields":[{"key":"status","label":"Status","type":"select","options":["open","done"],"default":"open","required":true}]}`,
})
if err != nil {
t.Fatalf("CreateCollection hidden: %v", err)
}
// Restricted member: granted access to the visible collection only.
u := createTestUser(t, s, "restricted@test.com", "Restricted", "password123")
if err := s.AddWorkspaceMember(ws.ID, u.ID, "editor"); err != nil {
t.Fatalf("AddWorkspaceMember: %v", err)
}
if err := s.SetMemberCollectionAccess(ws.ID, u.ID, "specific", []string{visibleColl.ID}); err != nil {
t.Fatalf("SetMemberCollectionAccess: %v", err)
}
// Backdate the workspace row so it can't shadow the items.
pastTS := "2020-01-01T00:00:00Z"
if _, err := s.db.Exec(s.q(`UPDATE workspaces SET updated_at = ? WHERE id = ?`), pastTS, ws.ID); err != nil {
t.Fatalf("backdate workspace: %v", err)
}
// Visible item first, then a NEWER hidden item — if the freshness
// query doesn't filter by visibility, the hidden item's timestamp
// leaks through.
visibleItem, err := s.CreateItem(ws.ID, visibleColl.ID, models.ItemCreate{Title: "Visible work"})
if err != nil {
t.Fatalf("CreateItem visible: %v", err)
}
time.Sleep(1100 * time.Millisecond)
hiddenItem, err := s.CreateItem(ws.ID, hiddenColl.ID, models.ItemCreate{Title: "Hidden work"})
if err != nil {
t.Fatalf("CreateItem hidden: %v", err)
}
if !hiddenItem.UpdatedAt.After(visibleItem.UpdatedAt) {
t.Fatalf("setup invariant: hidden item should be newer than visible item (visible=%v hidden=%v)",
visibleItem.UpdatedAt, hiddenItem.UpdatedAt)
}
list, err := s.GetUserWorkspaces(u.ID)
if err != nil {
t.Fatalf("GetUserWorkspaces: %v", err)
}
var got *models.Workspace
for i := range list {
if list[i].ID == ws.ID {
got = &list[i]
}
}
if got == nil {
t.Fatalf("workspace %s not in GetUserWorkspaces result", ws.ID)
}
if !got.UpdatedAt.Equal(visibleItem.UpdatedAt) {
t.Errorf("restricted member UpdatedAt = %v, want visible item activity %v (leak check: hidden item = %v)",
got.UpdatedAt, visibleItem.UpdatedAt, hiddenItem.UpdatedAt)
}
if got.UpdatedAt.Equal(hiddenItem.UpdatedAt) || got.UpdatedAt.After(hiddenItem.UpdatedAt) {
t.Errorf("restricted member UpdatedAt %v leaks hidden-item activity %v", got.UpdatedAt, hiddenItem.UpdatedAt)
}
}
func TestWorkspaceUniqueSlug(t *testing.T) {
s := testStore(t)
ws1, _ := s.CreateWorkspace(models.WorkspaceCreate{Name: "Test"})
ws2, _ := s.CreateWorkspace(models.WorkspaceCreate{Name: "Test"})
if ws1.Slug == ws2.Slug {
t.Error("duplicate slugs should not be allowed")
}
if ws2.Slug != "test-2" {
t.Errorf("expected slug 'test-2', got %q", ws2.Slug)
}
}
func TestWorkspaceSettingsHydrateStructuredContext(t *testing.T) {
s := testStore(t)
settings, err := models.SerializeWorkspaceSettings(&models.WorkspaceSettings{
Context: &models.WorkspaceContext{
Repositories: []models.WorkspaceRepository{
{Name: "docapp", Role: "primary", Path: ".", Repo: "PerpetualSoftware/pad"},
{Name: "pad-web", Role: "docs", Path: "../pad-web", Repo: "PerpetualSoftware/pad-web"},
},
Commands: &models.WorkspaceCommands{
Build: "make install",
Test: "go test ./...",
Web: "cd web && npm run build",
},
Deployment: &models.WorkspaceDeployment{
Mode: "local",
BaseURL: "http://127.0.0.1:7777",
},
},
})
if err != nil {
t.Fatalf("SerializeWorkspaceSettings error: %v", err)
}
ws, err := s.CreateWorkspace(models.WorkspaceCreate{
Name: "Machine Readable",
Settings: settings,
})
if err != nil {
t.Fatalf("CreateWorkspace error: %v", err)
}
if ws.Context == nil {
t.Fatal("expected created workspace to expose structured context")
}
if len(ws.Context.Repositories) != 2 {
t.Fatalf("expected 2 repositories in context, got %#v", ws.Context.Repositories)
}
got, err := s.GetWorkspaceBySlug(ws.Slug)
if err != nil {
t.Fatalf("GetWorkspaceBySlug error: %v", err)
}
if got.Context == nil || got.Context.Commands == nil {
t.Fatalf("expected hydrated commands, got %#v", got.Context)
}
if got.Context.Commands.Build != "make install" {
t.Fatalf("expected build command make install, got %q", got.Context.Commands.Build)
}
if got.Context.Deployment == nil || got.Context.Deployment.BaseURL != "http://127.0.0.1:7777" {
t.Fatalf("expected deployment base URL to round-trip, got %#v", got.Context.Deployment)
}
}
// --- Document Tests ---
func TestDocumentCRUD(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
// Create
doc, err := s.CreateDocument(ws.ID, models.DocumentCreate{
Title: "My Doc",
Content: "Hello world",
DocType: "notes",
Status: "draft",
})
if err != nil {
t.Fatalf("CreateDocument error: %v", err)
}
if doc.Title != "My Doc" {
t.Errorf("expected title 'My Doc', got %q", doc.Title)
}
if doc.Slug != "my-doc" {
t.Errorf("expected slug 'my-doc', got %q", doc.Slug)
}
// Get
got, err := s.GetDocument(doc.ID)
if err != nil {
t.Fatalf("GetDocument error: %v", err)
}
if got.Content != "Hello world" {
t.Errorf("expected content 'Hello world', got %q", got.Content)
}
// Update
newContent := "Updated content"
updated, err := s.UpdateDocument(doc.ID, models.DocumentUpdate{
Content: &newContent,
})
if err != nil {
t.Fatalf("UpdateDocument error: %v", err)
}
if updated.Content != "Updated content" {
t.Errorf("expected updated content, got %q", updated.Content)
}
// List
docs, err := s.ListDocuments(ws.ID, models.DocumentListParams{})
if err != nil {
t.Fatalf("ListDocuments error: %v", err)
}
if len(docs) != 1 {
t.Errorf("expected 1 document, got %d", len(docs))
}
// Delete
err = s.DeleteDocument(doc.ID)
if err != nil {
t.Fatalf("DeleteDocument error: %v", err)
}
// Should not appear in list
docs, _ = s.ListDocuments(ws.ID, models.DocumentListParams{})
if len(docs) != 0 {
t.Error("deleted document still appears in list")
}
// Restore
restored, err := s.RestoreDocument(doc.ID)
if err != nil {
t.Fatalf("RestoreDocument error: %v", err)
}
if restored.Status != "draft" {
t.Errorf("expected restored status 'draft', got %q", restored.Status)
}
docs, _ = s.ListDocuments(ws.ID, models.DocumentListParams{})
if len(docs) != 1 {
t.Error("restored document not in list")
}
}
func TestDocumentListFilters(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
// Create docs of different types and statuses
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Roadmap", DocType: "roadmap", Status: "active"})
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Plan", DocType: "plan", Status: "active"})
s.CreateDocument(ws.ID, models.DocumentCreate{Title: "Notes", DocType: "notes", Status: "draft"})
// Filter by type
docs, _ := s.ListDocuments(ws.ID, models.DocumentListParams{Type: "roadmap"})
if len(docs) != 1 {
t.Errorf("type filter: expected 1, got %d", len(docs))
}
// Filter by status
docs, _ = s.ListDocuments(ws.ID, models.DocumentListParams{Status: "active"})
if len(docs) != 2 {
t.Errorf("status filter: expected 2, got %d", len(docs))
}
// Filter by pinned
pinned := true
docs, _ = s.ListDocuments(ws.ID, models.DocumentListParams{Pinned: &pinned})
if len(docs) != 0 {
t.Errorf("pinned filter: expected 0, got %d", len(docs))
}
}
func TestVersionCreation(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "My Doc", "Version 1")
// First content update — should create a version (no previous versions exist)
v2 := "Version 2"
s.UpdateDocument(doc.ID, models.DocumentUpdate{Content: &v2})
versions, err := s.ListVersions(doc.ID)
if err != nil {
t.Fatalf("ListVersions error: %v", err)
}
if len(versions) != 1 {
t.Fatalf("expected 1 version after first update, got %d", len(versions))
}
// Second rapid update (same actor, same source, within throttle) — should NOT create a version
v3 := "Version 3"
s.UpdateDocument(doc.ID, models.DocumentUpdate{Content: &v3})
versions, _ = s.ListVersions(doc.ID)
if len(versions) != 1 {
t.Fatalf("expected 1 version after rapid second update (throttled), got %d", len(versions))
}
// Resolve the version to verify content
doc2, _ := s.GetDocument(doc.ID)
resolved, _ := s.ListVersionsResolved(doc.ID, doc2.Content)
if resolved[0].Content != "Version 1" {
t.Errorf("expected resolved version content 'Version 1', got %q", resolved[0].Content)
}
}
func TestVersionCreationActorChange(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "My Doc", "Original")
// Update as user
v2 := "User edit"
s.UpdateDocument(doc.ID, models.DocumentUpdate{Content: &v2, LastModifiedBy: "user", Source: "web"})
// Update as agent — should force a new version despite throttle
v3 := "Agent edit"
s.UpdateDocument(doc.ID, models.DocumentUpdate{Content: &v3, LastModifiedBy: "agent", Source: "cli"})
versions, _ := s.ListVersions(doc.ID)
if len(versions) != 2 {
t.Fatalf("expected 2 versions (actor change forces version), got %d", len(versions))
}
}
func TestVersionNotCreatedWithoutContentChange(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "My Doc", "Content")
// Update only status — should NOT create a version
newStatus := "active"
s.UpdateDocument(doc.ID, models.DocumentUpdate{Status: &newStatus})
versions, _ := s.ListVersions(doc.ID)
if len(versions) != 0 {
t.Errorf("expected 0 versions for non-content change, got %d", len(versions))
}
}
func TestDocumentLinkRename(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
origDoc := createTestDoc(t, s, ws.ID, "Old Name", "The original doc")
refDoc := createTestDoc(t, s, ws.ID, "Referencing Doc", "See [[Old Name]] for details")
// Rename the original document
newTitle := "New Name"
if _, err := s.UpdateDocument(origDoc.ID, models.DocumentUpdate{Title: &newTitle}); err != nil {
t.Fatalf("rename error: %v", err)
}
// The referencing doc should now have [[New Name]]
updated, _ := s.GetDocument(refDoc.ID)
if updated.Content != "See [[New Name]] for details" {
t.Errorf("link not updated: %q", updated.Content)
}
}
func TestFTSSearch(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var docsCollID string
for _, c := range colls {
if c.Slug == "docs" {
docsCollID = c.ID
break
}
}
s.CreateItem(ws.ID, docsCollID, models.ItemCreate{Title: "Auth Flow", Content: "OAuth2 authentication flow for API"})
s.CreateItem(ws.ID, docsCollID, models.ItemCreate{Title: "Data Model", Content: "Database schema and models"})
resp, err := s.Search(SearchParams{Query: "authentication"})
if err != nil {
t.Fatalf("Search error: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("expected 1 result, got %d", len(resp.Results))
}
if len(resp.Results) > 0 && resp.Results[0].Item.Title != "Auth Flow" {
t.Errorf("expected 'Auth Flow', got %q", resp.Results[0].Item.Title)
}
}
func TestFTSSearchScoped(t *testing.T) {
s := testStore(t)
ws1 := createTestWorkspace(t, s, "Workspace 1")
ws2 := createTestWorkspace(t, s, "Workspace 2")
s.SeedDefaultCollections(ws1.ID)
s.SeedDefaultCollections(ws2.ID)
colls1, _ := s.ListCollections(ws1.ID)
colls2, _ := s.ListCollections(ws2.ID)
var docs1ID, docs2ID string
for _, c := range colls1 {
if c.Slug == "docs" {
docs1ID = c.ID
break
}
}
for _, c := range colls2 {
if c.Slug == "docs" {
docs2ID = c.ID
break
}
}
s.CreateItem(ws1.ID, docs1ID, models.ItemCreate{Title: "Doc A", Content: "authentication in workspace 1"})
s.CreateItem(ws2.ID, docs2ID, models.ItemCreate{Title: "Doc B", Content: "authentication in workspace 2"})
// Unscoped — should find both
resp, _ := s.Search(SearchParams{Query: "authentication"})
if len(resp.Results) != 2 {
t.Errorf("unscoped: expected 2 results, got %d", len(resp.Results))
}
// Scoped — should find one
resp, _ = s.Search(SearchParams{Query: "authentication", Workspace: ws1.Slug})
if len(resp.Results) != 1 {
t.Errorf("scoped: expected 1 result, got %d", len(resp.Results))
}
}
func TestSearchCollectionFilter(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID, ideasID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
}
if c.Slug == "ideas" {
ideasID = c.ID
}
}
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Fix authentication bug", Fields: `{"status":"open","priority":"high"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Refactor authentication module", Fields: `{"status":"done","priority":"medium"}`})
s.CreateItem(ws.ID, ideasID, models.ItemCreate{Title: "New authentication provider", Fields: `{"status":"new"}`})
// Unfiltered — should find all 3
resp, err := s.Search(SearchParams{Query: "authentication", Workspace: ws.Slug})
if err != nil {
t.Fatalf("unfiltered search: %v", err)
}
if len(resp.Results) != 3 {
t.Errorf("unfiltered: expected 3 results, got %d", len(resp.Results))
}
// Filter by collection slug — only tasks
resp, err = s.Search(SearchParams{Query: "authentication", Workspace: ws.Slug, Collection: "tasks"})
if err != nil {
t.Fatalf("collection filter search: %v", err)
}
if len(resp.Results) != 2 {
t.Errorf("collection=tasks: expected 2 results, got %d", len(resp.Results))
}
for _, r := range resp.Results {
if r.Item.CollectionSlug != "tasks" {
t.Errorf("expected collection 'tasks', got %q", r.Item.CollectionSlug)
}
}
// Filter by collection slug — only ideas
resp, err = s.Search(SearchParams{Query: "authentication", Workspace: ws.Slug, Collection: "ideas"})
if err != nil {
t.Fatalf("collection=ideas search: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("collection=ideas: expected 1 result, got %d", len(resp.Results))
}
}
func TestSearchFieldFilters(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
break
}
}
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Fix login bug", Fields: `{"status":"open","priority":"high"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Fix logout bug", Fields: `{"status":"open","priority":"low"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Fix signup bug", Fields: `{"status":"done","priority":"high"}`})
// Filter by status=open — should find 2
resp, err := s.Search(SearchParams{
Query: "bug",
Workspace: ws.Slug,
FieldFilters: map[string]string{"status": "open"},
})
if err != nil {
t.Fatalf("field filter search: %v", err)
}
if len(resp.Results) != 2 {
t.Errorf("status=open: expected 2 results, got %d", len(resp.Results))
}
// Filter by priority=high — should find 2
resp, err = s.Search(SearchParams{
Query: "bug",
Workspace: ws.Slug,
FieldFilters: map[string]string{"priority": "high"},
})
if err != nil {
t.Fatalf("priority filter search: %v", err)
}
if len(resp.Results) != 2 {
t.Errorf("priority=high: expected 2 results, got %d", len(resp.Results))
}
// Combine filters: status=open AND priority=high — should find 1
resp, err = s.Search(SearchParams{
Query: "bug",
Workspace: ws.Slug,
FieldFilters: map[string]string{"status": "open", "priority": "high"},
})
if err != nil {
t.Fatalf("combined filter search: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("status=open+priority=high: expected 1 result, got %d", len(resp.Results))
}
if len(resp.Results) > 0 && resp.Results[0].Item.Title != "Fix login bug" {
t.Errorf("expected 'Fix login bug', got %q", resp.Results[0].Item.Title)
}
}
func TestSearchCollectionAndFieldFilters(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID, ideasID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
}
if c.Slug == "ideas" {
ideasID = c.ID
}
}
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Improve search speed", Fields: `{"status":"open","priority":"high"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Search UI redesign", Fields: `{"status":"done","priority":"medium"}`})
s.CreateItem(ws.ID, ideasID, models.ItemCreate{Title: "Search autocomplete feature", Fields: `{"status":"new"}`})
// Collection + field filter: tasks with status=open
resp, err := s.Search(SearchParams{
Query: "search",
Workspace: ws.Slug,
Collection: "tasks",
FieldFilters: map[string]string{"status": "open"},
})
if err != nil {
t.Fatalf("combined collection+field filter: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("tasks+status=open: expected 1 result, got %d", len(resp.Results))
}
if len(resp.Results) > 0 && resp.Results[0].Item.Title != "Improve search speed" {
t.Errorf("expected 'Improve search speed', got %q", resp.Results[0].Item.Title)
}
}
func TestSearchPagination(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
break
}
}
// Create 5 items all matching "widget"
for i := 0; i < 5; i++ {
s.CreateItem(ws.ID, tasksID, models.ItemCreate{
Title: fmt.Sprintf("Widget task %d", i+1),
Fields: `{"status":"open","priority":"medium"}`,
})
}
// Default pagination — should get all 5
resp, err := s.Search(SearchParams{Query: "widget", Workspace: ws.Slug})
if err != nil {
t.Fatalf("default pagination: %v", err)
}
if resp.Total != 5 {
t.Errorf("expected total=5, got %d", resp.Total)
}
if len(resp.Results) != 5 {
t.Errorf("expected 5 results, got %d", len(resp.Results))
}
if resp.Limit != 50 {
t.Errorf("expected default limit=50, got %d", resp.Limit)
}
// Limit=2 — should get 2 results but total still 5
resp, err = s.Search(SearchParams{Query: "widget", Workspace: ws.Slug, Limit: 2})
if err != nil {
t.Fatalf("limit=2: %v", err)
}
if resp.Total != 5 {
t.Errorf("expected total=5, got %d", resp.Total)
}
if len(resp.Results) != 2 {
t.Errorf("expected 2 results, got %d", len(resp.Results))
}
if resp.Limit != 2 {
t.Errorf("expected limit=2, got %d", resp.Limit)
}
// Offset=3, Limit=2 — should get 2 results (items 4 and 5)
resp, err = s.Search(SearchParams{Query: "widget", Workspace: ws.Slug, Limit: 2, Offset: 3})
if err != nil {
t.Fatalf("offset=3, limit=2: %v", err)
}
if resp.Total != 5 {
t.Errorf("expected total=5, got %d", resp.Total)
}
if len(resp.Results) != 2 {
t.Errorf("expected 2 results, got %d", len(resp.Results))
}
if resp.Offset != 3 {
t.Errorf("expected offset=3, got %d", resp.Offset)
}
// Offset beyond results — should get 0 results
resp, err = s.Search(SearchParams{Query: "widget", Workspace: ws.Slug, Offset: 10})
if err != nil {
t.Fatalf("offset=10: %v", err)
}
if resp.Total != 5 {
t.Errorf("expected total=5, got %d", resp.Total)
}
if len(resp.Results) != 0 {
t.Errorf("expected 0 results, got %d", len(resp.Results))
}
}
func TestSearchSorting(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
break
}
}
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Alpha gadget", Fields: `{"status":"open"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Charlie gadget", Fields: `{"status":"open"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Bravo gadget", Fields: `{"status":"open"}`})
// Sort by title ascending
resp, err := s.Search(SearchParams{Query: "gadget", Workspace: ws.Slug, Sort: "title", Order: "asc"})
if err != nil {
t.Fatalf("sort by title: %v", err)
}
if len(resp.Results) != 3 {
t.Fatalf("expected 3 results, got %d", len(resp.Results))
}
if resp.Results[0].Item.Title != "Alpha gadget" {
t.Errorf("expected first='Alpha gadget', got %q", resp.Results[0].Item.Title)
}
if resp.Results[2].Item.Title != "Charlie gadget" {
t.Errorf("expected last='Charlie gadget', got %q", resp.Results[2].Item.Title)
}
// Sort by title descending
resp, err = s.Search(SearchParams{Query: "gadget", Workspace: ws.Slug, Sort: "title", Order: "desc"})
if err != nil {
t.Fatalf("sort by title desc: %v", err)
}
if resp.Results[0].Item.Title != "Charlie gadget" {
t.Errorf("expected first='Charlie gadget', got %q", resp.Results[0].Item.Title)
}
if resp.Results[2].Item.Title != "Alpha gadget" {
t.Errorf("expected last='Alpha gadget', got %q", resp.Results[2].Item.Title)
}
}
func TestSearchFacets(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID, ideasID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
}
if c.Slug == "ideas" {
ideasID = c.ID
}
}
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Fix auth widget", Fields: `{"status":"open","priority":"high"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Refactor widget module", Fields: `{"status":"done","priority":"medium"}`})
s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Widget tests", Fields: `{"status":"open","priority":"low"}`})
s.CreateItem(ws.ID, ideasID, models.ItemCreate{Title: "Widget dashboard", Fields: `{"status":"new"}`})
resp, err := s.Search(SearchParams{Query: "widget", Workspace: ws.Slug})
if err != nil {
t.Fatalf("search: %v", err)
}
if resp.Facets == nil {
t.Fatal("expected facets, got nil")
}
// Collection facets
if resp.Facets.Collections["tasks"] != 3 {
t.Errorf("expected tasks=3, got %d", resp.Facets.Collections["tasks"])
}
if resp.Facets.Collections["ideas"] != 1 {
t.Errorf("expected ideas=1, got %d", resp.Facets.Collections["ideas"])
}
// Status facets
if resp.Facets.Statuses["open"] != 2 {
t.Errorf("expected open=2, got %d", resp.Facets.Statuses["open"])
}
if resp.Facets.Statuses["done"] != 1 {
t.Errorf("expected done=1, got %d", resp.Facets.Statuses["done"])
}
if resp.Facets.Statuses["new"] != 1 {
t.Errorf("expected new=1, got %d", resp.Facets.Statuses["new"])
}
// Facets should reflect full result set even with pagination
resp, err = s.Search(SearchParams{Query: "widget", Workspace: ws.Slug, Limit: 1})
if err != nil {
t.Fatalf("paginated search: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("expected 1 result on page, got %d", len(resp.Results))
}
if resp.Facets.Collections["tasks"] != 3 {
t.Errorf("facets should be unpaginated: expected tasks=3, got %d", resp.Facets.Collections["tasks"])
}
}
func TestActivity(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
doc := createTestDoc(t, s, ws.ID, "Doc", "content")
_, _ = s.CreateActivity(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "created",
Actor: "user",
Source: "web",
})
_, _ = s.CreateActivity(models.Activity{
WorkspaceID: ws.ID,
DocumentID: doc.ID,
Action: "updated",
Actor: "agent",
Source: "skill",
})
// Workspace activity
activities, err := s.ListWorkspaceActivity(ws.ID, models.ActivityListParams{})
if err != nil {
t.Fatalf("ListWorkspaceActivity error: %v", err)
}
if len(activities) != 2 {
t.Errorf("expected 2 activities, got %d", len(activities))
}
// Filter by actor
activities, _ = s.ListWorkspaceActivity(ws.ID, models.ActivityListParams{Actor: "agent"})
if len(activities) != 1 {
t.Errorf("expected 1 agent activity, got %d", len(activities))
}
// Document activity
activities, _ = s.ListDocumentActivity(doc.ID, models.ActivityListParams{})
if len(activities) != 2 {
t.Errorf("expected 2 doc activities, got %d", len(activities))
}
}
// TestSearch_BareNumericQueryFindsItemByNumber verifies that typing a plain
// number into the search field (e.g. "843") resolves to the workspace-global
// item with that item_number. This lets the search palette double as a quick
// "go to item N" jump. See BUG-910.
func TestSearch_BareNumericQueryFindsItemByNumber(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID, ideasID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
}
if c.Slug == "ideas" {
ideasID = c.ID
}
}
// Create a few items so item_numbers are 1, 2, 3 (workspace-global).
if _, err := s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "First task"}); err != nil {
t.Fatalf("CreateItem 1: %v", err)
}
target, err := s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Target task"})
if err != nil {
t.Fatalf("CreateItem 2: %v", err)
}
if _, err := s.CreateItem(ws.ID, ideasID, models.ItemCreate{Title: "Some idea"}); err != nil {
t.Fatalf("CreateItem 3: %v", err)
}
// Sanity: the second item should have item_number = 2.
if target.ItemNumber == nil || *target.ItemNumber != 2 {
t.Fatalf("expected target.ItemNumber=2, got %v", target.ItemNumber)
}
// Bare numeric query "2" should find the item with item_number=2.
resp, err := s.Search(SearchParams{Query: "2", Workspace: ws.Slug})
if err != nil {
t.Fatalf("Search error: %v", err)
}
if len(resp.Results) == 0 {
t.Fatalf("expected at least 1 result for bare numeric query, got 0")
}
if resp.Results[0].Item.ID != target.ID {
gotNum := -1
if resp.Results[0].Item.ItemNumber != nil {
gotNum = *resp.Results[0].Item.ItemNumber
}
t.Errorf("expected target item first, got %q (item_number=%d)",
resp.Results[0].Item.Title, gotNum)
}
// A non-existent number should not surface as a direct hit.
resp, err = s.Search(SearchParams{Query: "9999", Workspace: ws.Slug})
if err != nil {
t.Fatalf("Search error: %v", err)
}
for _, r := range resp.Results {
if r.Item.ItemNumber != nil && *r.Item.ItemNumber == 9999 {
t.Errorf("did not expect any item with item_number=9999")
}
}
// Whitespace around a bare number should still resolve.
resp, err = s.Search(SearchParams{Query: " 2 ", Workspace: ws.Slug})
if err != nil {
t.Fatalf("Search error: %v", err)
}
if len(resp.Results) == 0 || resp.Results[0].Item.ID != target.ID {
t.Errorf("expected whitespace-padded numeric query to resolve to target item")
}
}
// TestSearch_BareNumericQueryDedupsAgainstFTS guards the fix for the Codex
// review finding on the BUG-864/910 PR: when a bare-numeric query also
// matches FTS via the item's title/content (because the title literally
// contains the digit string), the direct hit must not also appear as an
// FTS row — otherwise pagination shrinks page 0 below `limit` and shifts
// later pages. With the FTS WHERE-clause exclusion, the item appears
// exactly once and Total counts it exactly once.
func TestSearch_BareNumericQueryDedupsAgainstFTS(t *testing.T) {
s := testStore(t)
ws := createTestWorkspace(t, s, "Test")
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
break
}
}
// First item gets item_number=1 (workspace-global).
target, err := s.CreateItem(ws.ID, tasksID, models.ItemCreate{
Title: "Reference number 1 in title",
Content: "This task talks about the digit 1 a lot. 1 is everywhere.",
})
if err != nil {
t.Fatalf("CreateItem target: %v", err)
}
if target.ItemNumber == nil || *target.ItemNumber != 1 {
t.Fatalf("expected target.ItemNumber=1, got %v", target.ItemNumber)
}
resp, err := s.Search(SearchParams{Query: "1", Workspace: ws.Slug})
if err != nil {
t.Fatalf("Search error: %v", err)
}
// The target must appear exactly once across results.
count := 0
for _, r := range resp.Results {
if r.Item.ID == target.ID {
count++
}
}
if count != 1 {
t.Errorf("expected target item to appear exactly once, got %d occurrences", count)
}
// Total must count the target exactly once too — not double-counted as
// (1 direct hit) + (1 FTS hit) = 2.
if resp.Total != len(resp.Results) {
t.Errorf("expected Total=%d (= len(Results)), got Total=%d", len(resp.Results), resp.Total)
}
}
// TestSearch_BareNumericQueryPaginatesAcrossWorkspaces guards the round-2
// Codex finding: in global search (WorkspaceIDs scoping, no single
// `Workspace`), a bare numeric query like "1" can match one item per
// workspace. Direct hits must be paginated together with FTS results so
// that (limit, offset) honours the full ordered list — not return all
// direct hits on page 0 ignoring `limit`, and not skip them entirely on
// page 1.
func TestSearch_BareNumericQueryPaginatesAcrossWorkspaces(t *testing.T) {
s := testStore(t)
ws1 := createTestWorkspace(t, s, "WS One")
ws2 := createTestWorkspace(t, s, "WS Two")
ws3 := createTestWorkspace(t, s, "WS Three")
for _, ws := range []*models.Workspace{ws1, ws2, ws3} {
s.SeedDefaultCollections(ws.ID)
colls, _ := s.ListCollections(ws.ID)
var tasksID string
for _, c := range colls {
if c.Slug == "tasks" {
tasksID = c.ID
break
}
}
// Item #1 in each workspace.
if _, err := s.CreateItem(ws.ID, tasksID, models.ItemCreate{Title: "Task one in " + ws.Name}); err != nil {
t.Fatalf("CreateItem in %s: %v", ws.Name, err)
}
}
allWs := []string{ws1.ID, ws2.ID, ws3.ID}
// Page 0: limit=1 — must return exactly one direct hit, not all three.
resp, err := s.Search(SearchParams{Query: "1", WorkspaceIDs: allWs, Limit: 1})
if err != nil {
t.Fatalf("page 0: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("page 0: expected 1 result, got %d", len(resp.Results))
}
if resp.Total != 3 {
t.Errorf("page 0: expected Total=3 (all three direct hits), got %d", resp.Total)
}
page0ID := ""
if len(resp.Results) > 0 {
page0ID = resp.Results[0].Item.ID
}
// Page 1: limit=1, offset=1 — must return the second direct hit, NOT
// fall through to FTS rows after dropping all direct hits.
resp, err = s.Search(SearchParams{Query: "1", WorkspaceIDs: allWs, Limit: 1, Offset: 1})
if err != nil {
t.Fatalf("page 1: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("page 1: expected 1 result, got %d", len(resp.Results))
}
if resp.Total != 3 {
t.Errorf("page 1: expected Total=3, got %d", resp.Total)
}
if len(resp.Results) > 0 && resp.Results[0].Item.ID == page0ID {
t.Errorf("page 1: expected a different direct hit than page 0, got the same item %q", page0ID)
}
// Page 2: limit=1, offset=2 — third direct hit.
resp, err = s.Search(SearchParams{Query: "1", WorkspaceIDs: allWs, Limit: 1, Offset: 2})
if err != nil {
t.Fatalf("page 2: %v", err)
}
if len(resp.Results) != 1 {
t.Errorf("page 2: expected 1 result, got %d", len(resp.Results))
}
// Single-page (limit=10) — must return all three direct hits, in
// deterministic order (by workspace_id then id).
resp, err = s.Search(SearchParams{Query: "1", WorkspaceIDs: allWs, Limit: 10})
if err != nil {
t.Fatalf("single page: %v", err)
}
if len(resp.Results) != 3 {
t.Errorf("single page: expected 3 results, got %d", len(resp.Results))
}
if resp.Total != 3 {
t.Errorf("single page: expected Total=3, got %d", resp.Total)
}
// Stable ordering — same query twice gives the same order.
resp2, _ := s.Search(SearchParams{Query: "1", WorkspaceIDs: allWs, Limit: 10})
for i := range resp.Results {
if i >= len(resp2.Results) {
break
}
if resp.Results[i].Item.ID != resp2.Results[i].Item.ID {
t.Errorf("ordering not deterministic at index %d: %q vs %q",
i, resp.Results[i].Item.ID, resp2.Results[i].Item.ID)
}
}
}
// TestParseItemNumber covers the helper that gates the bare-numeric search path.
func TestParseItemNumber(t *testing.T) {
cases := []struct {
in string
num int
ok bool
}{
{"843", 843, true},
{"1", 1, true},
{" 42 ", 42, true},
{"", 0, false},
{"0", 0, false}, // zero is not a valid item number
{"abc", 0, false},
{"843a", 0, false},
{"a843", 0, false},
{"TASK-843", 0, false}, // hyphens go through parseItemRef instead
{"-5", 0, false},
{"12.5", 0, false},
{"9999999", 0, false}, // exceeds upper bound
}
for _, c := range cases {
num, ok := parseItemNumber(c.in)
if num != c.num || ok != c.ok {
t.Errorf("parseItemNumber(%q) = (%d, %v), want (%d, %v)", c.in, num, ok, c.num, c.ok)
}
}
}
func TestSlugify(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"My App", "my-app"},
{"Phase 1: Core API", "phase-1-core-api"},
{"Hello World!!!", "hello-world"},
{" spaces ", "spaces"},
{"UPPERCASE", "uppercase"},
{"already-slugified", "already-slugified"},
{"dots.and.more", "dots-and-more"},
{"Dave's Workspace", "daves-workspace"},
{"it's a test", "its-a-test"},
}
for _, tt := range tests {
got := slugify(tt.input)
if got != tt.expected {
t.Errorf("slugify(%q) = %q, want %q", tt.input, got, tt.expected)
}
}
}