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.
176 lines
4.9 KiB
Go
176 lines
4.9 KiB
Go
package caldav
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestListEventsParse exercises every Event field via a fake REPORT response:
|
|
// a DATE-TIME event, an all-day DATE event, a recurring RRULE event, plus
|
|
// fields that are dropped by splitLine's param-stripping (DTSTART;VALUE=DATE)
|
|
// to confirm the all-day detection happens at the raw-line level.
|
|
func TestListEventsParse(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != "REPORT" {
|
|
t.Errorf("method = %s, want REPORT", r.Method)
|
|
}
|
|
body, _ := io.ReadAll(r.Body)
|
|
if !strings.Contains(string(body), `<c:comp-filter name="VEVENT">`) {
|
|
t.Errorf("REPORT body missing VEVENT comp-filter: %s", body)
|
|
}
|
|
if !strings.Contains(string(body), `<c:time-range`) {
|
|
t.Errorf("REPORT body missing time-range: %s", body)
|
|
}
|
|
w.WriteHeader(207)
|
|
_, _ = io.WriteString(w, eventsReportBody)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
c := New(srv.URL+"/dav/calendars/m/", "u", "p")
|
|
opts := ListEventsOpts{
|
|
TimeMin: time.Date(2026, 5, 15, 0, 0, 0, 0, time.UTC),
|
|
TimeMax: time.Date(2026, 5, 22, 0, 0, 0, 0, time.UTC),
|
|
}
|
|
events, err := c.ListEvents(context.Background(), c.BaseURL+"Work/", opts)
|
|
if err != nil {
|
|
t.Fatalf("ListEvents: %v", err)
|
|
}
|
|
if len(events) != 3 {
|
|
t.Fatalf("expected 3 events, got %d: %+v", len(events), events)
|
|
}
|
|
// Event 1: regular DATE-TIME
|
|
e1 := events[0]
|
|
if e1.UID != "ev-1@example" {
|
|
t.Errorf("events[0].UID = %q", e1.UID)
|
|
}
|
|
if e1.Summary != "Meeting with Leonard" {
|
|
t.Errorf("events[0].Summary = %q", e1.Summary)
|
|
}
|
|
if e1.AllDay {
|
|
t.Errorf("events[0].AllDay = true, want false (DATE-TIME)")
|
|
}
|
|
wantStart := time.Date(2026, 5, 16, 10, 0, 0, 0, time.UTC)
|
|
if !e1.Start.Equal(wantStart) {
|
|
t.Errorf("events[0].Start = %v, want %v", e1.Start, wantStart)
|
|
}
|
|
if e1.Location != "Munich, OG3" {
|
|
t.Errorf("events[0].Location = %q", e1.Location)
|
|
}
|
|
if !strings.HasSuffix(e1.URL, "/Work/ev-1.ics") {
|
|
t.Errorf("events[0].URL = %q", e1.URL)
|
|
}
|
|
if e1.Recurring {
|
|
t.Errorf("events[0].Recurring = true, want false")
|
|
}
|
|
|
|
// Event 2: all-day (VALUE=DATE)
|
|
e2 := events[1]
|
|
if e2.UID != "ev-2@example" {
|
|
t.Errorf("events[1].UID = %q", e2.UID)
|
|
}
|
|
if !e2.AllDay {
|
|
t.Errorf("events[1].AllDay = false, want true (DATE)")
|
|
}
|
|
wantAllDay := time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC)
|
|
if !e2.Start.Equal(wantAllDay) {
|
|
t.Errorf("events[1].Start = %v, want %v", e2.Start, wantAllDay)
|
|
}
|
|
|
|
// Event 3: recurring (RRULE present)
|
|
e3 := events[2]
|
|
if e3.UID != "ev-3@example" {
|
|
t.Errorf("events[2].UID = %q", e3.UID)
|
|
}
|
|
if !e3.Recurring {
|
|
t.Errorf("events[2].Recurring = false, want true (RRULE present)")
|
|
}
|
|
// Recurring event should surface only the literal DTSTART, not expanded.
|
|
wantRecStart := time.Date(2026, 5, 18, 14, 30, 0, 0, time.UTC)
|
|
if !e3.Start.Equal(wantRecStart) {
|
|
t.Errorf("events[2].Start = %v, want %v (literal DTSTART, no expansion)", e3.Start, wantRecStart)
|
|
}
|
|
}
|
|
|
|
func TestHasDateOnlyParam(t *testing.T) {
|
|
cases := []struct {
|
|
line string
|
|
want bool
|
|
}{
|
|
{"DTSTART:20260601T120000Z", false},
|
|
{"DTSTART;VALUE=DATE:20260601", true},
|
|
{"DTSTART;VALUE=DATE-TIME:20260601T120000Z", false},
|
|
{"DTSTART;TZID=Europe/Berlin:20260601T120000", false},
|
|
{"DTSTART;VALUE=DATE;TZID=foo:20260601", true},
|
|
}
|
|
for _, c := range cases {
|
|
got := hasDateOnlyParam(c.line)
|
|
if got != c.want {
|
|
t.Errorf("hasDateOnlyParam(%q) = %v, want %v", c.line, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
const eventsReportBody = `<?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/ev-1.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"e1"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:ev-1@example
|
|
SUMMARY:Meeting with Leonard
|
|
DTSTART:20260516T100000Z
|
|
DTEND:20260516T110000Z
|
|
LOCATION:Munich\, OG3
|
|
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/ev-2.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"e2"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:ev-2@example
|
|
SUMMARY:Holiday
|
|
DTSTART;VALUE=DATE:20260517
|
|
DTEND;VALUE=DATE:20260518
|
|
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/ev-3.ics</d:href>
|
|
<d:propstat>
|
|
<d:prop>
|
|
<d:getetag>"e3"</d:getetag>
|
|
<cal:calendar-data>BEGIN:VCALENDAR
|
|
BEGIN:VEVENT
|
|
UID:ev-3@example
|
|
SUMMARY:Weekly standup
|
|
DTSTART:20260518T143000Z
|
|
DTEND:20260518T150000Z
|
|
RRULE:FREQ=WEEKLY;BYDAY=MO
|
|
END:VEVENT
|
|
END:VCALENDAR</cal:calendar-data>
|
|
</d:prop>
|
|
<d:status>HTTP/1.1 200 OK</d:status>
|
|
</d:propstat>
|
|
</d:response>
|
|
</d:multistatus>`
|