Files
projax/web/dashboard_events_test.go
mAi d49ad219a4 feat(phase 3l vevents): VEVENT support on dashboard — closes mgmt-parity gap
caldav package:
- Event struct: UID, Summary, Start, End, AllDay, Location, Description,
  Recurring, URL — read-only, no writeback
- ListEvents(ctx, calendarURL, ListEventsOpts{TimeMin, TimeMax}) issues
  REPORT calendar-query with server-side <c:time-range> filter
- parseVEvents handles DATE vs DATE-TIME (via hasDateOnlyParam since
  splitLine strips ;VALUE=DATE), RRULE-present → Recurring=true with NO
  expansion (literal DTSTART only)
- 2 unit tests: full parse (DATE-TIME, all-day, recurring), hasDateOnlyParam

web dashboard:
- dashboardEvent / dashboardEventGroup types
- collectEvents fans out 4-worker pool across every caldav-list link,
  fixed 7-day window from now, sort start-asc, cap 50, group by day
- dayLabelFor: Today / Tomorrow / weekday-day-month
- Events card on /dashboard between Tasks and Issues, with empty-collapse
- 2 integration tests with stubbed CalDAV: surfaces upcoming + DATE/RRULE
  rendering; empty-collapse with no links

design.md §5 (CalDAV) + §Dashboard updated; mgmt-teardown plan's one
blocking gap is now closed.
2026-05-16 00:57:52 +02:00

163 lines
5.1 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()
code, body := get(t, h, "/dashboard")
if code != 200 {
t.Fatalf("GET /dashboard → %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, "/dashboard")
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>`
}