Hard-replaces the 5i projax.views table per m's Q10 pick (2026-05-29):
no real data to preserve after a few hours, and the shape changes are
big enough that a clean recreate beats a 6-step ALTER.
Schema (migration 0017_views_redesign.sql):
- id (uuid), slug (text, format-CHECK'd, UNIQUE), name, icon,
filter_json (jsonb — INCLUDES view_type per m's Q2), sort_field,
sort_dir, group_by, sort_order, show_count, last_used_at,
created_at, updated_at.
- DROPPED: pinned, is_default_for, view_type column. m's Q9 picked
MRU (last_used_at) over per-page-default; Q2 placed view_type
inside filter_json so the JSON owns the canonical render spec.
- Constraints: slug regex, sort_dir enum. NO view_type CHECK — the
JSON-shape validator owns it now.
- Indexes: slug UNIQUE, (sort_order, name), (last_used_at DESC).
- updated_at trigger reused; projax_admin ownership preserved.
Store (store/views.go rewrite):
- View struct: Slug as the user-facing key; uuid kept on ID for the
legacy `?view=<uuid>` 302-redirect path that lands in slice C.
- ListViews ordered by sort_order, name (matches sidebar).
- GetView(slug) + GetViewByID(uuid). MostRecentView() drives the
/views landing redirect (slice B).
- TouchView(slug) bumps last_used_at fire-and-forget.
- ReorderViews([]slugs) wires the column for slice G's drag UI.
- CreateView server-assigns sort_order = MAX+1 inside the tx.
- UpdateView replaces every writeable field; renames are supported.
- Validation: slug format regex + reserved-list rejection +
filter_json JSON well-formed check before round-trip.
- ErrViewNotFound / ErrViewSlugTaken / ErrViewSlugReserved /
ErrViewSlugFormat surface to handlers as the typed error set.
Cleanup of the 5i overlay (drops what the new shape obsoletes):
- web/views.go: gutted to a stub. applySavedView, applyDefaultView,
overlayURLFields, filterQueryToJSON, filterJSONToQuery,
filterFromJSONPayload, anySliceToStrings + every old handler
(handleViewsIndex, handleViewCreate, handleViewWrite, handleViewEdit,
handleViewRedirect, handleViewDelete) deleted.
- web/server.go: dropped the /views route registrations and the
applySavedView + applyDefaultView calls in handleTree.
DefaultBanner data-map field removed.
- web/tree_filter.go: TreeFilter.ViewID field removed; ParseTreeFilter
and QueryString stop reading/emitting ?view=.
- web/templates/views.tmpl and view_edit.tmpl deleted.
- web/templates/tree_section.tmpl: default-banner block deleted.
- web/views_test.go: deleted (every test was against the 5i shape).
Between slice A and slice B, /views/* URLs return 404 by design.
Slice B reintroduces the route family in paliad-shape:
GET /views → MRU landing
GET /views/{slug} → render
GET /views/new → editor
GET /views/{slug}/edit → editor
POST /views, /views/{slug}, /views/{slug}/delete → CRUD
Tests (store/views_test.go, new):
- TestViewSlugCRUD — create / get-by-slug / get-by-id / rename /
delete round-trip, including rename-leaves-old-slug-gone.
- TestViewSlugFormatRejected — uppercase, underscore, leading dash,
length-cap, empty all surface ErrViewSlugFormat.
- TestViewReservedSlugRejected — tree/dashboard/calendar/timeline/graph
and friends all reject with ErrViewSlugReserved.
- TestViewSlugCollision — duplicate slug surfaces ErrViewSlugTaken.
- TestViewMRU — TouchView + MostRecentView ordering against a
controlled pair of slugs (resilient to other suites' touched views).
- TestViewReorder — ReorderViews rewrites sort_order ascending.
Web tests stay green (the 5i overlay tests are gone, the rest don't
touch the views shape).
247 lines
8.1 KiB
Go
247 lines
8.1 KiB
Go
package store_test
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/store"
|
|
)
|
|
|
|
// connect mirrors db_test's connect helper. The store package owns its own
|
|
// integration tests (Phase 5j Slice A introduced this file alongside the
|
|
// schema redesign); it shares the same env-var convention to skip when no
|
|
// DB is wired up.
|
|
func connect(t *testing.T) (*pgxpool.Pool, *store.Store) {
|
|
t.Helper()
|
|
url := os.Getenv("PROJAX_DB_URL")
|
|
if url == "" {
|
|
url = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if url == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, url)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
return pool, store.New(pool)
|
|
}
|
|
|
|
// uniqueSlug suffixes a base slug with a timestamp so parallel test runs
|
|
// don't collide on the views_slug_uniq index.
|
|
func uniqueSlug(prefix string) string {
|
|
return prefix + "-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
}
|
|
|
|
func TestViewSlugCRUD(t *testing.T) {
|
|
pool, s := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
slug := uniqueSlug("p5j-a-crud")
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug LIKE 'p5j-a-crud-%' OR slug LIKE 'p5j-a-renamed-%'`)
|
|
|
|
// Create.
|
|
created, err := s.CreateView(ctx, store.ViewInput{
|
|
Slug: slug,
|
|
Name: "Slice A CRUD",
|
|
FilterJSON: []byte(`{"view_type":"list","tags":["work"]}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create: %v", err)
|
|
}
|
|
if created.Slug != slug {
|
|
t.Errorf("slug = %q, want %q", created.Slug, slug)
|
|
}
|
|
if created.ID == "" {
|
|
t.Error("ID should be populated on create")
|
|
}
|
|
if created.SortOrder < 0 {
|
|
t.Errorf("sort_order should be >= 0 (server-assigned), got %d", created.SortOrder)
|
|
}
|
|
|
|
// GetView by slug.
|
|
got, err := s.GetView(ctx, slug)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if string(got.FilterJSON) != `{"view_type": "list", "tags": ["work"]}` && string(got.FilterJSON) != `{"tags": ["work"], "view_type": "list"}` {
|
|
// Postgres jsonb normalises key order — accept either ordering.
|
|
// Verify it round-trips structurally.
|
|
if !strings.Contains(string(got.FilterJSON), `"view_type"`) || !strings.Contains(string(got.FilterJSON), `"tags"`) {
|
|
t.Errorf("filter_json did not round-trip view_type+tags: %s", got.FilterJSON)
|
|
}
|
|
}
|
|
|
|
// GetViewByID (legacy 5i 302-redirect path uses this).
|
|
byID, err := s.GetViewByID(ctx, created.ID)
|
|
if err != nil {
|
|
t.Fatalf("get by id: %v", err)
|
|
}
|
|
if byID.Slug != slug {
|
|
t.Errorf("by-id lookup returned wrong slug: %q", byID.Slug)
|
|
}
|
|
|
|
// Update — rename slug + change filter.
|
|
renamed := uniqueSlug("p5j-a-renamed")
|
|
updated, err := s.UpdateView(ctx, slug, store.ViewInput{
|
|
Slug: renamed,
|
|
Name: "Renamed",
|
|
FilterJSON: []byte(`{"view_type":"card"}`),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
if updated.Slug != renamed {
|
|
t.Errorf("renamed slug = %q, want %q", updated.Slug, renamed)
|
|
}
|
|
if _, err := s.GetView(ctx, slug); !errors.Is(err, store.ErrViewNotFound) {
|
|
t.Errorf("old slug should be ErrViewNotFound after rename, got %v", err)
|
|
}
|
|
|
|
// Delete.
|
|
if err := s.DeleteView(ctx, renamed); err != nil {
|
|
t.Fatalf("delete: %v", err)
|
|
}
|
|
if _, err := s.GetView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
|
t.Errorf("post-delete get should be ErrViewNotFound, got %v", err)
|
|
}
|
|
if err := s.DeleteView(ctx, renamed); !errors.Is(err, store.ErrViewNotFound) {
|
|
t.Errorf("second delete should be ErrViewNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestViewSlugFormatRejected(t *testing.T) {
|
|
pool, s := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
bad := []string{
|
|
"", // empty
|
|
"UPPER", // uppercase
|
|
"under_score", // underscore
|
|
"-leading-dash", // leading dash
|
|
"a." + strings.Repeat("x", 100), // too long + invalid char
|
|
strings.Repeat("a", 64), // length cap is 63 (1 + 62)
|
|
}
|
|
for _, slug := range bad {
|
|
_, err := s.CreateView(ctx, store.ViewInput{
|
|
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
|
})
|
|
if !errors.Is(err, store.ErrViewSlugFormat) {
|
|
t.Errorf("slug=%q expected ErrViewSlugFormat, got %v", slug, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestViewReservedSlugRejected(t *testing.T) {
|
|
_, s := connect(t)
|
|
ctx := context.Background()
|
|
for _, slug := range []string{"tree", "dashboard", "calendar", "timeline", "graph", "new", "edit", "admin", "views"} {
|
|
_, err := s.CreateView(ctx, store.ViewInput{
|
|
Slug: slug, Name: "x", FilterJSON: []byte(`{}`),
|
|
})
|
|
if !errors.Is(err, store.ErrViewSlugReserved) {
|
|
t.Errorf("reserved slug %q should be rejected, got %v", slug, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestViewSlugCollision(t *testing.T) {
|
|
pool, s := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
slug := uniqueSlug("p5j-a-collision")
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
|
|
|
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "First"}); err != nil {
|
|
t.Fatalf("first create: %v", err)
|
|
}
|
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: "Second"}); !errors.Is(err, store.ErrViewSlugTaken) {
|
|
t.Errorf("duplicate slug should be ErrViewSlugTaken, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestViewMRU(t *testing.T) {
|
|
pool, s := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
a := uniqueSlug("p5j-a-mru-a")
|
|
b := uniqueSlug("p5j-a-mru-b")
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2)`, a, b)
|
|
|
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: a, Name: "A"}); err != nil {
|
|
t.Fatalf("create a: %v", err)
|
|
}
|
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: b, Name: "B"}); err != nil {
|
|
t.Fatalf("create b: %v", err)
|
|
}
|
|
|
|
// MostRecentView with no touches yet — when no view in the table has
|
|
// last_used_at set, MRU returns nil. (Other tests may have left their
|
|
// own touched views, so we only assert on the slugs we control.)
|
|
if err := s.TouchView(ctx, a); err != nil {
|
|
t.Fatalf("touch a: %v", err)
|
|
}
|
|
time.Sleep(20 * time.Millisecond)
|
|
if err := s.TouchView(ctx, b); err != nil {
|
|
t.Fatalf("touch b: %v", err)
|
|
}
|
|
|
|
mru, err := s.MostRecentView(ctx)
|
|
if err != nil {
|
|
t.Fatalf("mru: %v", err)
|
|
}
|
|
// Other tests' touched views may rank higher; we only assert that
|
|
// when MRU is one of OURS, the most-recently-touched (b) wins over a.
|
|
// To guarantee this test's signal even with contention from other
|
|
// suites, check b's last_used_at > a's last_used_at directly.
|
|
aV, _ := s.GetView(ctx, a)
|
|
bV, _ := s.GetView(ctx, b)
|
|
if aV.LastUsedAt == nil || bV.LastUsedAt == nil {
|
|
t.Fatal("both views should have last_used_at after touch")
|
|
}
|
|
if !bV.LastUsedAt.After(*aV.LastUsedAt) {
|
|
t.Errorf("b.last_used_at should be after a.last_used_at; a=%v b=%v", aV.LastUsedAt, bV.LastUsedAt)
|
|
}
|
|
if mru == nil {
|
|
t.Error("MostRecentView returned nil even though touches landed")
|
|
}
|
|
}
|
|
|
|
func TestViewReorder(t *testing.T) {
|
|
pool, s := connect(t)
|
|
defer pool.Close()
|
|
ctx := context.Background()
|
|
a := uniqueSlug("p5j-a-reorder-a")
|
|
b := uniqueSlug("p5j-a-reorder-b")
|
|
c := uniqueSlug("p5j-a-reorder-c")
|
|
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug IN ($1, $2, $3)`, a, b, c)
|
|
|
|
for _, slug := range []string{a, b, c} {
|
|
if _, err := s.CreateView(ctx, store.ViewInput{Slug: slug, Name: slug}); err != nil {
|
|
t.Fatalf("create %s: %v", slug, err)
|
|
}
|
|
}
|
|
// Reorder c → b → a.
|
|
if err := s.ReorderViews(ctx, []string{c, b, a}); err != nil {
|
|
t.Fatalf("reorder: %v", err)
|
|
}
|
|
cV, _ := s.GetView(ctx, c)
|
|
bV, _ := s.GetView(ctx, b)
|
|
aV, _ := s.GetView(ctx, a)
|
|
if cV.SortOrder != 0 || bV.SortOrder != 1 || aV.SortOrder != 2 {
|
|
t.Errorf("reorder yielded sort_orders c=%d b=%d a=%d, want 0,1,2",
|
|
cV.SortOrder, bV.SortOrder, aV.SortOrder)
|
|
}
|
|
}
|