Files
projax/caldav/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

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>`