Per m's Q1 pick (b) (2026-05-29): legacy `/`, `/dashboard`, `/calendar`,
`/timeline`, `/graph` become `/views/{system-slug}`. Old routes
301-redirect to the new ones with chip params preserved; the legacy
?view=<uuid> param from 5i is resolved through the uuid → slug map
when present so old bookmarks land on the right user view.
System views (web/system_views.go):
- SystemView struct (Slug / Name / Icon / URL) — code-resident, never
rows in projax.views.
- AllSystemViews() returns the canonical five: tree, dashboard,
calendar, timeline, graph. Display order matches the existing
sidebar.
- LookupSystemView(slug) returns the matching entry or nil; the
reserved-slug list in store.IsReservedViewSlug (slice A) is kept
in sync.
- legacyRedirect(systemSlug) handler 301s with chip-param preservation
+ uuid → slug resolution for any leftover ?view=<uuid>.
Routes (web/server.go):
- GET /views/tree → handleTree (was GET /)
- GET /views/dashboard → handleDashboard
- GET /views/timeline → handleTimeline
- GET /views/calendar → handleCalendar
- GET /views/graph → handleGraph
- GET / → 301 → /views/tree
- GET /dashboard → 301 → /views/dashboard
- GET /timeline → 301 → /views/timeline
- GET /calendar → 301 → /views/calendar
- GET /graph → 301 → /views/graph
- POST action endpoints (/dashboard/task/*, /dashboard/pin, /admin/*)
stay where they are — those are RPC-ish, not page renders.
handleTree: dropped the `r.URL.Path != "/"` guard — the only entry
point now is /views/tree, mounted via the new route. Slice F removes
any residual references; this slice keeps the handler reachable.
computeChipCounts grew a `base string` arg so chip URLs anchor on the
caller's route (/views/tree for the system tree, /views/{slug} for
saved views). PageViewTypes recognises both legacy and /views/ keys
during the transition.
Template hrefs / hx-gets bulk-updated to the new URLs:
- layout.tmpl: every sidebar + bottom-nav entry points at
/views/{system-slug}. Active-state checks updated alongside.
- tree_section.tmpl, tree_card.tmpl, tree_kanban.tmpl: clear-filter
/ clear-all hrefs → /views/tree.
- calendar*.tmpl, timeline_section.tmpl, graph.tmpl,
dashboard_section.tmpl: every internal nav + filter link points at
the /views/{slug} surface.
- detail.tmpl, error.tmpl: cancel / back-to-tree → /views/tree.
Test-source updates (per the 5c sharpened rule):
- ~100 test paths bulk-rewritten from /dashboard /calendar /timeline
/graph (and `/`) to their /views/{slug} counterparts. The
behaviour-preservation contract holds: status codes + body shapes
for the rendered pages stay the same; only the URL anchoring the
test changes.
- layout_test.go: sidebar href assertions updated to /views/{slug}.
- view_type_test.go (Q2 + Q3 follow-up): PageViewTypes lookup table
updated to use the new route keys.
- 2 deliberate behaviour-change assertions land: TestLegacyRedirects
expects 301 on the old URLs (was 200); TestTreeRenders fetches
/views/tree (the new home) instead of /.
Internal go-source URL emissions (dashboard.go, calendar.go,
timeline.go) updated to the new BasePath so chip + refresh URLs round
through /views/{slug} correctly.
New tests:
- TestSystemViewLookup — AllSystemViews shape + LookupSystemView
round-trip + unknown-slug nil.
- TestLegacyRedirects — every legacy URL 301s to its new home with
chip params preserved.
- TestLegacyViewUUIDRedirect — old `?view=<uuid>` URLs land on the
resolved slug per m's Q3 pick.
419 lines
14 KiB
Go
419 lines
14 KiB
Go
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"github.com/m/projax/db"
|
|
"github.com/m/projax/store"
|
|
"github.com/m/projax/web"
|
|
)
|
|
|
|
var (
|
|
migrateOnce sync.Once
|
|
migrateErr error
|
|
)
|
|
|
|
func mustServer(t *testing.T) (*web.Server, *pgxpool.Pool) {
|
|
t.Helper()
|
|
dbURL := os.Getenv("PROJAX_DB_URL")
|
|
if dbURL == "" {
|
|
dbURL = os.Getenv("SUPABASE_DATABASE_URL")
|
|
}
|
|
if dbURL == "" {
|
|
t.Skip("no PROJAX_DB_URL / SUPABASE_DATABASE_URL set — skipping HTTP integration test")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
defer cancel()
|
|
pool, err := pgxpool.New(ctx, dbURL)
|
|
if err != nil {
|
|
t.Fatalf("pool: %v", err)
|
|
}
|
|
if err := pool.Ping(ctx); err != nil {
|
|
t.Skipf("DB unreachable: %v", err)
|
|
}
|
|
if os.Getenv("PROJAX_SKIP_MIGRATE") != "1" {
|
|
migrateOnce.Do(func() { migrateErr = db.ApplyMigrations(ctx, pool) })
|
|
if migrateErr != nil {
|
|
t.Fatalf("migrate: %v", migrateErr)
|
|
}
|
|
}
|
|
srv, err := web.New(store.New(pool), slog.New(slog.NewTextHandler(io.Discard, nil)))
|
|
if err != nil {
|
|
t.Fatalf("server: %v", err)
|
|
}
|
|
return srv, pool
|
|
}
|
|
|
|
func get(t *testing.T, h http.Handler, url string) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, url, nil)
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
return w.Result().StatusCode, string(body)
|
|
}
|
|
|
|
// post submits an application/x-www-form-urlencoded POST to the given
|
|
// path. Mirrors the get helper for form-flavored writeback tests.
|
|
func post(t *testing.T, h http.Handler, path string, form url.Values) (int, string) {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
return w.Result().StatusCode, string(body)
|
|
}
|
|
|
|
func TestTreeRenders(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/views/tree")
|
|
if code != 200 {
|
|
t.Fatalf("GET /views/tree status %d body=%s", code, body)
|
|
}
|
|
// /admin/classify used to live in the nav; Phase 3o consolidated all
|
|
// admin links under the new /admin index. Assert /admin instead.
|
|
for _, want := range []string{"<h1>Tree</h1>", "/i/dev", "/i/home", `href="/admin"`} {
|
|
if !strings.Contains(body, want) {
|
|
t.Errorf("body missing %q", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLayoutHasViewportMeta proves every chrome-bearing page carries the
|
|
// viewport meta tag added in Phase 3i. Without it iOS Safari renders pages
|
|
// at 980px and the user must pinch-zoom to read anything. We probe one
|
|
// representative GET on each layout-rendered route.
|
|
func TestLayoutHasViewportMeta(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
for _, path := range []string{"/views/tree", "/views/dashboard", "/views/calendar", "/views/graph", "/admin/bulk", "/admin/classify", "/new", "/login"} {
|
|
_, body := get(t, h, path)
|
|
if !strings.Contains(body, `name="viewport"`) {
|
|
t.Errorf("GET %s: missing <meta name=\"viewport\">", path)
|
|
}
|
|
if !strings.Contains(body, `width=device-width`) {
|
|
t.Errorf("GET %s: viewport meta does not set width=device-width", path)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestHealthzSurfacesVersion proves /healthz returns the version line as
|
|
// well as the ok marker. Phase 3p — closes the silent-deploy-rot gap so a
|
|
// worker can verify "deploy actually rolled" with an unauthenticated curl
|
|
// (compare against `git rev-parse --short HEAD` before assuming the latest
|
|
// merge is live).
|
|
func TestHealthzSurfacesVersion(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
srv.Version = "abc1234"
|
|
h := srv.Routes()
|
|
code, body := get(t, h, "/healthz")
|
|
if code != 200 {
|
|
t.Fatalf("GET /healthz → %d", code)
|
|
}
|
|
if !strings.Contains(body, "ok") {
|
|
t.Errorf("body should contain 'ok', got %q", body)
|
|
}
|
|
if !strings.Contains(body, "version: abc1234") {
|
|
t.Errorf("body should contain 'version: abc1234', got %q", body)
|
|
}
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/healthz")
|
|
// Body is two lines now (Phase 3p): "ok\nversion: <sha>\n". Assert the
|
|
// 200 status + "ok" leader, not exact equality, so the version line can
|
|
// grow without breaking this guard.
|
|
if code != 200 || !strings.HasPrefix(body, "ok") {
|
|
t.Fatalf("healthz: %d %q", code, body)
|
|
}
|
|
}
|
|
|
|
func TestDetailRendersEditableForm(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/i/dev")
|
|
if code != 200 {
|
|
t.Fatalf("status %d body=%s", code, body)
|
|
}
|
|
if !strings.Contains(body, `form method="post" action="/i/dev"`) {
|
|
t.Errorf("edit form missing for /i/dev")
|
|
}
|
|
if !strings.Contains(body, `name="tags"`) {
|
|
t.Errorf("tags input missing")
|
|
}
|
|
if !strings.Contains(body, `name="management"`) {
|
|
t.Errorf("management input missing")
|
|
}
|
|
}
|
|
|
|
func TestDetailShowsManagementChips(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
// dev.projax is the manually-promoted item from before Phase 1.5 — should
|
|
// already carry management=['mai'] after the backfill+sync pass.
|
|
code, body := get(t, srv.Routes(), "/i/dev.projax")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "mgmt-mai") {
|
|
t.Errorf("expected mgmt-mai chip on /i/dev.projax, body did not include it")
|
|
}
|
|
}
|
|
|
|
func TestClassifyListsMaiRoots(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
code, body := get(t, srv.Routes(), "/admin/classify")
|
|
if code != 200 {
|
|
t.Fatalf("status %d", code)
|
|
}
|
|
if !strings.Contains(body, "Classify root mai-managed items") &&
|
|
!strings.Contains(body, "No unclassified roots") {
|
|
t.Errorf("classify page body unexpected: %q", body)
|
|
}
|
|
}
|
|
|
|
func TestReparentRoundTrip(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
// Create a fresh root mai-managed item via the reverse-sync path so the
|
|
// test never collides with another project. The sync trigger drops the
|
|
// mirror at parent_id=NULL — exactly the case /admin/classify handles.
|
|
maiID := "phase15-test-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from mai.projects where id=$1`, maiID)
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, maiID)
|
|
}()
|
|
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into mai.projects (id, name, status) values ($1, $2, 'active')`,
|
|
maiID, "Reparent test "+maiID,
|
|
); err != nil {
|
|
t.Fatalf("seed mai.projects: %v", err)
|
|
}
|
|
|
|
var nParents int
|
|
if err := pool.QueryRow(ctx,
|
|
`select cardinality(parent_ids) from projax.items where slug=$1`, maiID,
|
|
).Scan(&nParents); err != nil {
|
|
t.Fatalf("read mirror: %v", err)
|
|
}
|
|
if nParents != 0 {
|
|
t.Fatalf("expected mirror at root (no parents), got %d parents", nParents)
|
|
}
|
|
|
|
var devID string
|
|
if err := pool.QueryRow(ctx,
|
|
`select id from projax.items where slug='dev' and cardinality(parent_ids) = 0`,
|
|
).Scan(&devID); err != nil {
|
|
t.Fatalf("dev: %v", err)
|
|
}
|
|
|
|
form := url.Values{}
|
|
form.Set("parent_id", devID)
|
|
req := httptest.NewRequest(http.MethodPost, "/i/"+maiID+"/reparent", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
w := httptest.NewRecorder()
|
|
h.ServeHTTP(w, req)
|
|
if w.Result().StatusCode != http.StatusSeeOther {
|
|
body, _ := io.ReadAll(w.Result().Body)
|
|
t.Fatalf("reparent status %d body=%s", w.Result().StatusCode, body)
|
|
}
|
|
if loc := w.Result().Header.Get("Location"); loc != "/i/dev."+maiID {
|
|
t.Errorf("Location = %q, want /i/dev.%s", loc, maiID)
|
|
}
|
|
|
|
var parents []string
|
|
if err := pool.QueryRow(ctx,
|
|
`select array(select unnest(parent_ids)::text) from projax.items where slug=$1`, maiID,
|
|
).Scan(&parents); err != nil {
|
|
t.Fatalf("post-reparent read: %v", err)
|
|
}
|
|
if len(parents) != 1 || parents[0] != devID {
|
|
t.Errorf("parent_ids after reparent = %v, want [%s]", parents, devID)
|
|
}
|
|
}
|
|
|
|
func TestMultiParentBothPathsRouteToSameRow(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
slug := "p15-multi-" + strings.ReplaceAll(time.Now().UTC().Format("150405.000"), ".", "")
|
|
defer func() {
|
|
_, _ = pool.Exec(context.Background(), `delete from projax.items where slug=$1`, slug)
|
|
}()
|
|
|
|
var dev, work 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='work' and cardinality(parent_ids)=0`).Scan(&work); err != nil {
|
|
t.Fatalf("work: %v", err)
|
|
}
|
|
if _, err := pool.Exec(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'Multi', $1, ARRAY[$2,$3]::uuid[])`,
|
|
slug, dev, work,
|
|
); err != nil {
|
|
t.Fatalf("insert multi: %v", err)
|
|
}
|
|
|
|
for _, p := range []string{"dev." + slug, "work." + slug} {
|
|
code, body := get(t, h, "/i/"+p)
|
|
if code != 200 {
|
|
t.Fatalf("GET /i/%s → %d", p, code)
|
|
}
|
|
if !strings.Contains(body, "Multi") {
|
|
t.Errorf("body for /i/%s missing item title 'Multi'", p)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestTreeRendersKanbanWhenViewTypeIsKanban verifies the Phase 5i Slice C
|
|
// dispatch: GET /?view_type=kanban renders the kanban board (with the
|
|
// group-by chip strip) instead of the forest. group_by defaults to status.
|
|
func TestTreeRendersKanbanWhenViewTypeIsKanban(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
_, body := get(t, h, "/views/tree?view_type=kanban")
|
|
if !strings.Contains(body, `class="kanban-board"`) {
|
|
t.Error("?view_type=kanban should render the kanban board")
|
|
}
|
|
if !strings.Contains(body, `class="groupby-chip`) {
|
|
t.Error("kanban view should render the group-by chip strip")
|
|
}
|
|
if strings.Contains(body, `<ul class="forest">`) {
|
|
t.Error("kanban view should not render the tree forest")
|
|
}
|
|
}
|
|
|
|
// TestTreeRendersCardGridWhenViewTypeIsCard verifies Phase 5i Slice B
|
|
// dispatch: `?view_type=card` renders the flat tile grid instead of the
|
|
// forest, and the view-type chip strip is present in either view. Unknown
|
|
// view_type values fall back to list with the chip-strip showing list as
|
|
// active.
|
|
func TestTreeRendersCardGridWhenViewTypeIsCard(t *testing.T) {
|
|
srv, pool := mustServer(t)
|
|
defer pool.Close()
|
|
h := srv.Routes()
|
|
// List view (default): forest markup expected; tree-card-grid absent.
|
|
_, listBody := get(t, h, "/views/tree")
|
|
if !strings.Contains(listBody, `<ul class="forest">`) {
|
|
t.Error("default GET / should render the tree forest")
|
|
}
|
|
if strings.Contains(listBody, `class="tree-card-grid"`) {
|
|
t.Error("default GET / should not render the card grid")
|
|
}
|
|
if !strings.Contains(listBody, `view-type-chip-row`) {
|
|
t.Error("view-type chip strip should appear on every view")
|
|
}
|
|
// Card view: card grid present, forest absent.
|
|
_, cardBody := get(t, h, "/views/tree?view_type=card")
|
|
if !strings.Contains(cardBody, `class="tree-card-grid"`) {
|
|
t.Error("GET /?view_type=card should render the card grid")
|
|
}
|
|
if strings.Contains(cardBody, `<ul class="forest">`) {
|
|
t.Error("GET /?view_type=card should not render the tree forest")
|
|
}
|
|
// Unknown view_type falls back to list.
|
|
_, unknownBody := get(t, h, "/views/tree?view_type=junk")
|
|
if !strings.Contains(unknownBody, `<ul class="forest">`) {
|
|
t.Error("unknown view_type should fall back to list")
|
|
}
|
|
}
|
|
|
|
// TestProjectFilterScopesTreeToDescendants verifies the Phase 5i Slice A
|
|
// project scope semantics end-to-end: ?project=<path> narrows / to the picked
|
|
// item + descendants; ?project_descendants=0 narrows further to the picked
|
|
// item alone. Both round-trip through ParseTreeFilter + TreeFilter.Matches +
|
|
// the tree handler.
|
|
func TestProjectFilterScopesTreeToDescendants(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"), ".", "")
|
|
parentSlug := "p5i-parent-" + stamp
|
|
childSlug := "p5i-child-" + stamp
|
|
siblingSlug := "p5i-sib-" + stamp
|
|
|
|
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)
|
|
}
|
|
var parentID, childID, siblingID string
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Parent', $1, ARRAY[$2]::uuid[]) returning id`,
|
|
parentSlug, dev).Scan(&parentID); err != nil {
|
|
t.Fatalf("seed parent: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Child', $1, ARRAY[$2]::uuid[]) returning id`,
|
|
childSlug, parentID).Scan(&childID); err != nil {
|
|
t.Fatalf("seed child: %v", err)
|
|
}
|
|
if err := pool.QueryRow(ctx,
|
|
`insert into projax.items (kind, title, slug, parent_ids) values (array['project']::text[], 'P5i Sib', $1, ARRAY[$2]::uuid[]) returning id`,
|
|
siblingSlug, dev).Scan(&siblingID); err != nil {
|
|
t.Fatalf("seed sibling: %v", err)
|
|
}
|
|
defer pool.Exec(context.Background(), `delete from projax.items where id in ($1,$2,$3)`, childID, parentID, siblingID)
|
|
|
|
parentPath := "dev." + parentSlug
|
|
parentLink := `href="/i/` + parentPath + `"`
|
|
childLink := `href="/i/` + parentPath + `.` + childSlug + `"`
|
|
siblingLink := `href="/i/dev.` + siblingSlug + `"`
|
|
|
|
// Descendants on (default): parent + child visible, sibling hidden.
|
|
_, withDesc := get(t, h, "/views/tree?project="+parentPath)
|
|
if !strings.Contains(withDesc, parentLink) {
|
|
t.Errorf("?project=%s should show parent row", parentPath)
|
|
}
|
|
if !strings.Contains(withDesc, childLink) {
|
|
t.Errorf("?project=%s should include descendant child row", parentPath)
|
|
}
|
|
if strings.Contains(withDesc, siblingLink) {
|
|
t.Errorf("?project=%s should exclude sibling row", parentPath)
|
|
}
|
|
|
|
// Descendants off: only the picked item, no children.
|
|
_, noDesc := get(t, h, "/views/tree?project="+parentPath+"&project_descendants=0")
|
|
if !strings.Contains(noDesc, parentLink) {
|
|
t.Errorf("?project_descendants=0 should still show the picked parent row")
|
|
}
|
|
if strings.Contains(noDesc, childLink) {
|
|
t.Errorf("?project_descendants=0 should hide the child row")
|
|
}
|
|
if strings.Contains(noDesc, siblingLink) {
|
|
t.Errorf("?project_descendants=0 should hide the sibling row")
|
|
}
|
|
}
|