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.
163 lines
5.1 KiB
Go
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>`
|
|
}
|