Files
projax/web/views_test.go
mAi e305f0e0ae feat(views): Phase 5j slice B — paliad-shape route family + render
Restores the /views URL family in the paliad shape m asked for:

  GET  /views                  → MRU 302 or onboarding shell
  GET  /views/{slug}           → render saved view as its own page
  GET  /views/new              → editor blank
  GET  /views/{slug}/edit      → editor existing
  POST /views                  → create
  POST /views/{slug}           → update
  POST /views/{slug}/delete    → delete
  POST /views/reorder          → drag-reorder hook (used in slice G)

Render path:
- handleViewRender resolves the slug against user views (slice C adds
  system views), touches last_used_at fire-and-forget so the next /views
  landing 302s here, then dispatches the same view_type renderers the
  tree page uses (list / card / kanban). filter_json is decoded into a
  TreeFilter + view_type + group_by; URL chip params overlay the saved
  filter so chips narrow the view further without losing the saved
  baseline. calendar / timeline view_types fall back to list in slice B;
  slice D wires their dedicated templates.

Editor path:
- handleViewEditor renders templates/view_editor.tmpl, a minimal form
  for slice B (slice D adds the live chip strip, slug auto-derivation,
  and the icon registry). Pre-fills every persisted field on edit.

Templates:
- views_landing.tmpl — index card list + "+ new view" link.
- view_render.tmpl — header (name + slug + edit/delete) + tree-section
  partial. Bundled with tree_section / tree_card / tree_kanban /
  project_chip so the rendered view shares the dispatch chain.
- view_editor.tmpl — form for create + edit.

Encoding:
- encodeFilterToJSON canonicalises (filter_query, view_type) into the
  filter_json shape. view_type lives INSIDE the JSON per m's Q2 pick.
- decodeViewSpec is the inverse — slice C's system-view code reuses it
  to convert SystemView definitions into the same shape.
- overlayURLOntoSavedFilter mirrors the 5i fix-shift pattern: URL chip
  values selectively override the saved baseline (q / tag / mgmt /
  status / has / show-archived / public / project / project_descendants).

Error mapping:
- writeViewError translates the typed store errors (ErrViewSlugFormat /
  Reserved / Taken / NotFound) into 400 / 409 with human-readable
  banners. handlers map ErrViewNotFound to 404 directly.

Tests (HTTP integration):
- TestViewsLandingOnboarding — empty store → shell with "+ New view".
- TestViewsLandingMRURedirects — touched view triggers 302 to it.
- TestViewRenderShowsSavedView — name + slug + view_type=card grid.
- TestViewRender404OnUnknownSlug — unknown slug 404s, no silent
  fall-back to tree.
- TestViewCreateAndDelete — POST /views creates; reserved slug 400s;
  POST /views/<slug>/delete removes the row.
- TestSavedViewFilterOverlay — ?tag=work narrows the saved view; URL
  chip values overlay the persisted filter.
2026-05-29 11:47:33 +02:00

198 lines
6.8 KiB
Go

package web_test
import (
"context"
"net/url"
"strings"
"testing"
"time"
)
// TestViewsLandingOnboarding asserts that GET /views with no views and no
// MRU renders the onboarding shell ("No saved views yet" + "+ New view").
func TestViewsLandingOnboarding(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
// Clear any leftover touched views from prior runs so the MRU 302
// doesn't fire and steal the response.
if _, err := pool.Exec(context.Background(),
`UPDATE projax.views SET last_used_at = NULL`); err != nil {
t.Fatalf("reset mru: %v", err)
}
// Also clear ALL views so the onboarding shell renders (othewise the
// landing still ListViews-displays them).
if _, err := pool.Exec(context.Background(), `DELETE FROM projax.views`); err != nil {
t.Fatalf("clear views: %v", err)
}
code, body := get(t, h, "/views")
if code != 200 {
t.Fatalf("GET /views status=%d body=%q", code, body)
}
if !strings.Contains(body, "No saved views yet") {
t.Error("onboarding shell should surface the no-views nudge")
}
if !strings.Contains(body, `href="/views/new"`) {
t.Error("onboarding shell should link to /views/new")
}
}
// TestViewsLandingMRURedirects asserts that GET /views 302s to the most
// recently used view when one exists.
func TestViewsLandingMRURedirects(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-landing-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
// Seed + touch.
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json, last_used_at)
VALUES ($1, 'P5j B Landing', '{"view_type":"list"}'::jsonb, now())`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
code, body := get(t, h, "/views")
if code != 302 {
t.Errorf("GET /views status=%d (want 302 to MRU); body=%q", code, body)
}
}
// TestViewRenderShowsSavedView asserts that GET /views/{slug} renders the
// view's name + slug in the header and the tree-section body.
func TestViewRenderShowsSavedView(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-render-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
if _, err := pool.Exec(context.Background(), `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5j B Render', '{"view_type":"card"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed: %v", err)
}
code, body := get(t, h, "/views/"+slug)
if code != 200 {
t.Fatalf("GET /views/<slug> status=%d body=%q", code, body)
}
if !strings.Contains(body, "P5j B Render") {
t.Error("render should surface the view's name")
}
if !strings.Contains(body, `/views/`+slug) {
t.Error("render should surface the view's slug in the header")
}
if !strings.Contains(body, `class="tree-card-grid"`) {
t.Error("view_type=card should render the card grid")
}
}
// TestViewRender404OnUnknownSlug — an unknown slug returns 404, not a
// silent fallback to the tree.
func TestViewRender404OnUnknownSlug(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
code, _ := get(t, h, "/views/this-slug-does-not-exist-anywhere-9876")
if code != 404 {
t.Errorf("unknown slug should 404, got %d", code)
}
}
// TestViewCreateAndDelete — POST /views creates; POST /views/<slug>/delete
// removes. Verifies the slug-format error path too.
func TestViewCreateAndDelete(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-crud-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
form := url.Values{}
form.Set("slug", slug)
form.Set("name", "P5j B CRUD")
form.Set("view_type", "list")
form.Set("filter_query", "tag=work")
code, _ := post(t, h, "/views", form)
if code != 303 {
t.Fatalf("create status=%d want 303", code)
}
// Reserved-slug 400.
form2 := url.Values{}
form2.Set("slug", "dashboard")
form2.Set("name", "Should be rejected")
form2.Set("view_type", "list")
code, body := post(t, h, "/views", form2)
if code != 400 {
t.Errorf("reserved-slug create should 400, got %d body=%q", code, body)
}
// Delete.
code, _ = post(t, h, "/views/"+slug+"/delete", url.Values{})
if code != 303 {
t.Errorf("delete status=%d want 303", code)
}
}
// TestSavedViewFilterOverlay — chip params on /views/<slug>?tag=x narrow
// the saved filter. Verifies the slice B render-path overlay.
func TestSavedViewFilterOverlay(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
h := srv.Routes()
ctx := context.Background()
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
slug := "p5j-b-overlay-" + stamp
devSlug := "p5j-b-overlay-d-" + stamp
homeSlug := "p5j-b-overlay-h-" + stamp
defer pool.Exec(context.Background(), `DELETE FROM projax.views WHERE slug = $1`, slug)
var dev, home 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)
}
if err := pool.QueryRow(ctx, `select id from projax.items where slug='home' and cardinality(parent_ids)=0`).Scan(&home); err != nil {
t.Fatalf("home: %v", err)
}
var devID, homeID string
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'P5jB Dev', $1, ARRAY[$2]::uuid[], ARRAY['work'])
RETURNING id`, devSlug, dev).Scan(&devID); err != nil {
t.Fatalf("seed dev item: %v", err)
}
if err := pool.QueryRow(ctx, `
INSERT INTO projax.items (kind, title, slug, parent_ids, tags)
VALUES (array['project']::text[], 'P5jB Home', $1, ARRAY[$2]::uuid[], ARRAY['home'])
RETURNING id`, homeSlug, home).Scan(&homeID); err != nil {
t.Fatalf("seed home item: %v", err)
}
defer pool.Exec(context.Background(), `DELETE FROM projax.items WHERE id IN ($1,$2)`, devID, homeID)
if _, err := pool.Exec(ctx, `
INSERT INTO projax.views (slug, name, filter_json)
VALUES ($1, 'P5jB Overlay', '{"view_type":"list"}'::jsonb)`, slug); err != nil {
t.Fatalf("seed view: %v", err)
}
devLink := `href="/i/dev.` + devSlug + `"`
homeLink := `href="/i/home.` + homeSlug + `"`
_, base := get(t, h, "/views/"+slug)
if !strings.Contains(base, devLink) {
t.Error("saved view without tag should show dev row")
}
if !strings.Contains(base, homeLink) {
t.Error("saved view without tag should show home row")
}
_, narrowed := get(t, h, "/views/"+slug+"?tag=work")
if !strings.Contains(narrowed, devLink) {
t.Error("URL chip tag=work should keep dev (work-tagged)")
}
if strings.Contains(narrowed, homeLink) {
t.Error("URL chip tag=work should hide home")
}
}