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/ 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//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/?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") } }