Files
projax/web/dashboard_events_test.go
mAi f820fa5830 feat(views): Phase 5j slice C — full URL migration + system views
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.
2026-05-29 11:59:26 +02:00

164 lines
5.2 KiB
Go

package web_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/m/projax/caldav"
"github.com/m/projax/web"
)
// TestDashboardEventsCardSurfacesUpcoming spins up a fake CalDAV server that
// answers REPORT for /Work-events/, seeds a projax item with a caldav-list
// link pointing at that fake calendar, and asserts /dashboard renders the
// Events card with the right row.
func TestDashboardEventsCardSurfacesUpcoming(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
// Build a fixed-time fake calendar with one tomorrow-event and one all-day
// event the day after.
now := time.Now().UTC()
tomorrow := now.AddDate(0, 0, 1)
dayAfter := now.AddDate(0, 0, 2)
icsBody := buildFakeEventsReportBody(t, tomorrow, dayAfter)
mux := http.NewServeMux()
mux.HandleFunc("/dav/calendars/m/Work-evts/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "REPORT" {
http.Error(w, "method", http.StatusMethodNotAllowed)
return
}
w.WriteHeader(207)
_, _ = io.WriteString(w, icsBody)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/dav/calendars/m/", "u", "p")}
// Seed projax item under dev with a caldav-list link to the fake calendar.
stamp := strings.ReplaceAll(time.Now().UTC().Format("150405.000000"), ".", "")
slug := "evt-fix-" + stamp
calURL := fake.URL + "/dav/calendars/m/Work-evts/"
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var dev, id 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,
`insert into projax.items (kind, title, slug, parent_ids)
values (array['project']::text[], 'evt', $1, ARRAY[$2]::uuid[])
returning id`,
slug, dev,
).Scan(&id); err != nil {
t.Fatalf("seed item: %v", err)
}
defer pool.Exec(context.Background(), `delete from projax.items where id=$1`, id)
if _, err := pool.Exec(ctx,
`insert into projax.item_links (item_id, ref_type, ref_id, rel)
values ($1, 'caldav-list', $2, 'tracks')`,
id, calURL,
); err != nil {
t.Fatalf("seed link: %v", err)
}
h := srv.Routes()
// The card-events markup lives on the Tasks tab (Phase 5h).
code, body := get(t, h, "/views/dashboard?view=tasks")
if code != 200 {
t.Fatalf("GET /views/dashboard?view=tasks → %d", code)
}
for _, want := range []string{
`card-events`,
`Upcoming dinner`,
`Munich`,
`/i/dev.` + slug,
} {
if !strings.Contains(body, want) {
t.Errorf("dashboard body missing %q", want)
}
}
if !strings.Contains(body, "ganztägig") {
t.Errorf("dashboard body should label the DATE-only event as 'ganztägig'")
}
}
// TestDashboardEventsCardCollapsesWhenEmpty: with no items linked to any
// CalDAV calendar and no filter active, the Events card collapses to the
// one-line muted note.
func TestDashboardEventsCardCollapsesWhenEmpty(t *testing.T) {
srv, pool := mustServer(t)
defer pool.Close()
// Wire a CalDAV client that always returns 0 events.
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(207)
_, _ = io.WriteString(w, `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:"></d:multistatus>`)
})
fake := httptest.NewServer(mux)
defer fake.Close()
srv.CalDAV = &web.CalDAVDeps{Client: caldav.New(fake.URL+"/", "u", "p")}
h := srv.Routes()
_, body := get(t, h, "/views/dashboard?view=tasks")
if !strings.Contains(body, "No upcoming events") {
t.Errorf("expected collapsed Events card with 'No upcoming events' note")
}
}
// buildFakeEventsReportBody returns a CalDAV REPORT multistatus body with one
// DATE-TIME event tomorrow at 18:00Z and one all-day DATE event the day
// after. Inputs are real UTC times so the test reads against the actual
// dashboard "next 7d" window from now.
func buildFakeEventsReportBody(t *testing.T, tomorrow, dayAfter time.Time) string {
t.Helper()
startDT := tomorrow.UTC().Format("20060102T150405Z")
endDT := tomorrow.Add(time.Hour).UTC().Format("20060102T150405Z")
allDayDate := dayAfter.UTC().Format("20060102")
dayAfterPlus1 := dayAfter.AddDate(0, 0, 1).UTC().Format("20060102")
return `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav/calendars/m/Work-evts/ev-1.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>"e1"</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
BEGIN:VEVENT
UID:ev-1@fake
SUMMARY:Upcoming dinner
DTSTART:` + startDT + `
DTEND:` + endDT + `
LOCATION:Munich
END:VEVENT
END:VCALENDAR</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav/calendars/m/Work-evts/ev-2.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>"e2"</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
BEGIN:VEVENT
UID:ev-2@fake
SUMMARY:All-hands offsite
DTSTART;VALUE=DATE:` + allDayDate + `
DTEND;VALUE=DATE:` + dayAfterPlus1 + `
END:VEVENT
END:VCALENDAR</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`
}