diff --git a/web/bulk.go b/web/bulk.go index fe4b66f..ba013b3 100644 --- a/web/bulk.go +++ b/web/bulk.go @@ -118,6 +118,29 @@ func bulkMatches(f TreeFilter, it *store.Item, itemLinkKinds map[string]struct{} return false } } + // Phase 5i Slice A: project scope. Same predicate as TreeFilter.Matches — + // at least one of the item's paths must equal ProjectPath, with the + // IncludeDescendants toggle gating the prefix-match for the subtree. + // bulkMatches was a near-clone of Matches() that wasn't updated when + // the project dim landed, so /admin/bulk silently ignored ?project=… + // (and the chip's hidden-input round-trip too). + if f.ProjectPath != "" { + prefix := f.ProjectPath + "." + hit := false + for _, p := range it.Paths { + if p == f.ProjectPath { + hit = true + break + } + if f.IncludeDescendants && strings.HasPrefix(p, prefix) { + hit = true + break + } + } + if !hit { + return false + } + } if f.Q != "" { q := strings.ToLower(f.Q) hit := strings.Contains(strings.ToLower(it.Title), q) || diff --git a/web/project_filter_test.go b/web/project_filter_test.go new file mode 100644 index 0000000..4edc9f9 --- /dev/null +++ b/web/project_filter_test.go @@ -0,0 +1,266 @@ +package web_test + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// projectFixture seeds a subtree shaped: +// +// dev/ (existing) +// -root (root of the test subtree) +// -child (descendant of root) +// -outside (sibling of root under dev — NOT a descendant) +// +// Returns the slugs + primary paths. Callers defer the row cleanup. +type projectFixture struct { + rootSlug, childSlug, outsideSlug string + rootPath, childPath, outsidePath string + rootID, childID, outsideID string +} + +func seedProjectFixture(t *testing.T, pool *pgxpool.Pool) projectFixture { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var dev string + if err := pool.QueryRow(ctx, `select id from projax.items where slug='dev' and cardinality(parent_ids)=0`).Scan(&dev); err != nil { + t.Fatalf("dev: %v", err) + } + stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "") + fx := projectFixture{ + rootSlug: "proj-root-" + stamp, + childSlug: "proj-child-" + stamp, + outsideSlug: "proj-outside-" + stamp, + } + // root + outside both live directly under dev. + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[]) + returning id`, + fx.rootSlug, dev, + ).Scan(&fx.rootID); err != nil { + t.Fatalf("seed root: %v", err) + } + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[]) + returning id`, + fx.outsideSlug, dev, + ).Scan(&fx.outsideID); err != nil { + t.Fatalf("seed outside: %v", err) + } + // child lives under root. + if err := pool.QueryRow(ctx, + `insert into projax.items (kind, title, slug, parent_ids) + values (array['project']::text[], $1, $1, ARRAY[$2]::uuid[]) + returning id`, + fx.childSlug, fx.rootID, + ).Scan(&fx.childID); err != nil { + t.Fatalf("seed child: %v", err) + } + fx.rootPath = "dev." + fx.rootSlug + fx.childPath = fx.rootPath + "." + fx.childSlug + fx.outsidePath = "dev." + fx.outsideSlug + return fx +} + +func cleanupProjectFixture(pool *pgxpool.Pool, fx projectFixture) { + pool.Exec(context.Background(), `delete from projax.items where id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID) +} + +// TestProjectFilterNarrowsTree exercises the / (tree) handler — applyTreeFilter +// passes the project filter through TreeFilter.Matches, so ?project= +// must show only root + descendants. +func TestProjectFilterNarrowsTree(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + h := srv.Routes() + + _, body := get(t, h, "/?project="+fx.rootPath) + if !strings.Contains(body, fx.rootPath) { + t.Errorf("tree ?project= missing root path %q", fx.rootPath) + } + if !strings.Contains(body, fx.childPath) { + t.Errorf("tree ?project= missing child path %q (descendants default ON)", fx.childPath) + } + if strings.Contains(body, fx.outsidePath) { + t.Errorf("tree ?project= leaked outside path %q", fx.outsidePath) + } +} + +// TestProjectFilterNarrowsTimeline — buildTimeline funnels items via +// q.Filter.Matches before fan-out, so ?project= must drop the +// creation row for the outside sibling but keep root + child. +func TestProjectFilterNarrowsTimeline(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + h := srv.Routes() + + _, body := get(t, h, "/timeline?refresh=1&project="+fx.rootPath) + if !strings.Contains(body, fx.rootPath) { + t.Errorf("timeline ?project= missing root creation row") + } + if !strings.Contains(body, fx.childPath) { + t.Errorf("timeline ?project= missing child creation row") + } + if strings.Contains(body, fx.outsidePath) { + t.Errorf("timeline ?project= leaked outside creation row %q", fx.outsidePath) + } +} + +// TestProjectFilterNarrowsCalendar — buildCalendar funnels items via +// q.Filter.Matches; rows surface from dated item_links. Seed a dated link +// on each fixture item, then verify scoping by ?project=. +func TestProjectFilterNarrowsCalendar(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + rootNote := "cal-root-" + fx.rootSlug + childNote := "cal-child-" + fx.childSlug + outsideNote := "cal-outside-" + fx.outsideSlug + if _, err := pool.Exec(ctx, + `insert into projax.item_links (item_id, ref_type, ref_id, rel, note, event_date) + values ($1, 'document', $2, 'contains', $3, current_date), + ($4, 'document', $5, 'contains', $6, current_date), + ($7, 'document', $8, 'contains', $9, current_date)`, + fx.rootID, "https://example.com/cal-root", rootNote, + fx.childID, "https://example.com/cal-child", childNote, + fx.outsideID, "https://example.com/cal-outside", outsideNote, + ); err != nil { + t.Fatalf("seed dated links: %v", err) + } + defer pool.Exec(context.Background(), `delete from projax.item_links where item_id in ($1, $2, $3)`, fx.rootID, fx.childID, fx.outsideID) + + h := srv.Routes() + _, body := get(t, h, "/calendar?refresh=1&project="+fx.rootPath) + if !strings.Contains(body, rootNote) { + t.Errorf("calendar ?project= missing root note %q", rootNote) + } + if !strings.Contains(body, childNote) { + t.Errorf("calendar ?project= missing child note %q (descendants ON)", childNote) + } + if strings.Contains(body, outsideNote) { + t.Errorf("calendar ?project= leaked outside note %q", outsideNote) + } +} + +// TestProjectFilterNarrowsDashboard — dashboard filters items via Matches +// when q.Filter.Active() is true. The Stale-projects card is the most +// reliable surface to verify since it iterates the full item set on +// every render. +func TestProjectFilterNarrowsDashboard(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + h := srv.Routes() + + _, body := get(t, h, "/dashboard?project="+fx.rootPath) + if !strings.Contains(body, fx.rootPath) { + t.Errorf("dashboard ?project= missing root path %q", fx.rootPath) + } + if strings.Contains(body, fx.outsidePath) { + t.Errorf("dashboard ?project= leaked outside path %q", fx.outsidePath) + } +} + +// TestProjectFilterNarrowsBulk reproduces the actual bug: /admin/bulk's +// bulkMatches was a near-clone of TreeFilter.Matches that never picked up +// the Phase 5i Slice A ProjectPath block, so ?project= silently +// ignored the filter. Pre-fix the outside item leaked into the bulk list. +func TestProjectFilterNarrowsBulk(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + h := srv.Routes() + + _, body := get(t, h, "/admin/bulk?project="+fx.rootPath) + if !strings.Contains(body, fx.rootPath) { + t.Errorf("bulk ?project= missing root path %q", fx.rootPath) + } + if !strings.Contains(body, fx.childPath) { + t.Errorf("bulk ?project= missing child path %q (descendants ON)", fx.childPath) + } + if strings.Contains(body, fx.outsidePath) { + t.Errorf("BUG: /admin/bulk ?project= leaked outside path %q — bulkMatches missing the ProjectPath gate", fx.outsidePath) + } +} + +// TestProjectFilterDescendantsToggle pins m's Q5 pick: the toggle is +// exposed explicitly. With project_descendants=0 the filter narrows to +// the single root item only — the child path must drop out. +func TestProjectFilterDescendantsToggle(t *testing.T) { + srv, pool := mustServer(t) + defer pool.Close() + fx := seedProjectFixture(t, pool) + defer cleanupProjectFixture(pool, fx) + h := srv.Routes() + + // Default (descendants on) — child included. + _, on := get(t, h, "/?project="+fx.rootPath) + if !strings.Contains(on, fx.childPath) { + t.Errorf("descendants=on should include child path %q", fx.childPath) + } + + // Toggled off — child dropped, root still in. + _, off := get(t, h, "/?project="+fx.rootPath+"&project_descendants=0") + if !strings.Contains(off, fx.rootPath) { + t.Errorf("descendants=off should still include root %q", fx.rootPath) + } + if strings.Contains(off, fx.childPath) { + t.Errorf("descendants=off leaked child path %q — IncludeDescendants gate not honoured", fx.childPath) + } +} + +// TestTimelineKindMultiValueSurvives mirrors the earlier calendar-filter +// fix: emits `` for the same X then closes the option. + checkSelected := func(kind string) { + idx := strings.Index(body, `; the selected attribute, if + // present, lives in that window. + end := strings.Index(body[idx:], ``) + if end < 0 { + t.Errorf("rendered form malformed near