fix(caldav): expand RRULE for recurring events

This commit is contained in:
2026-09-06 15:49:09 +02:00
parent f35121b3dc
commit 8e946964ec
11 changed files with 1223 additions and 126 deletions
+99 -3
View File
@@ -74,9 +74,9 @@ func TestICSDetailRouteRendersReadonlyEvent(t *testing.T) {
handler := s.Handler(emptyStaticFS{})
cookie := loginAs(t, handler, "alice", "password")
id := icssub.EventID(mustParseICS(t, icsDetailSample))
id := seriesID(t, icsDetailSample)
if id == "" {
t.Fatal("EventID must be non-empty")
t.Fatal("series ID must be non-empty")
}
req := httptest.NewRequest(http.MethodGet, "/calendar/"+url.PathEscape("!holidays")+"/"+id, nil)
@@ -147,7 +147,7 @@ func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
if !strings.Contains(body, "ICS detail event") {
t.Fatalf("month grid should list ICS events, got:\n%s", body)
}
id := icssub.EventID(mustParseICS(t, icsDetailSample))
id := seriesID(t, icsDetailSample)
// The grid renders the detail link with a "/web/calendar/!<ref>/<id>"
// shape (see eventLinkURL in calendar.templ). The "!" in the ref is
// not %-escaped by templ.URL — we observed un-escaped output in the
@@ -161,6 +161,102 @@ func TestMonthGridLinksICSEventToDetailPage(t *testing.T) {
}
}
// TestEventOccurrenceDaysExpandsRRule verifies that a recurring event
// (RRULE) is placed on every in-window occurrence day, not just its base
// date — the core of the "recurring series events missing" fix.
func TestEventOccurrenceDaysExpandsRRule(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:rec1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260803T090000Z\r\n" + // a Monday
"DTEND:20260803T100000Z\r\n" +
"RRULE:FREQ=WEEKLY;COUNT=4\r\n" +
"SUMMARY:weekly meeting\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
ev := mustParseICS(t, raw)
loc := time.UTC
// Window covering the whole of August 2026 (the 4 weekly occurrences:
// Aug 3, 10, 17, 24 are all within range).
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
dayIndex := make(map[string]int)
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
dayIndex[d.Format(dateLayout)] = i
}
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
wantDates := []string{"2026-08-03", "2026-08-10", "2026-08-17", "2026-08-24"}
for _, ws := range wantDates {
if _, ok := got[dayIndex[ws]]; !ok {
t.Errorf("expected recurring occurrence on %s, got days %v", ws, got)
}
}
if len(got) != 4 {
t.Errorf("expected exactly 4 in-window occurrence days, got %d (%v)", len(got), got)
}
}
// TestEventOccurrenceDaysSingleEvent verifies a non-recurring event occupies
// only its own day(s) (no spurious expansion).
func TestEventOccurrenceDaysSingleEvent(t *testing.T) {
const raw = "BEGIN:VCALENDAR\r\n" +
"VERSION:2.0\r\n" +
"PRODID:-//nidus//test//EN\r\n" +
"BEGIN:VEVENT\r\n" +
"UID:single1@nidus.test\r\n" +
"DTSTAMP:20260101T000000Z\r\n" +
"DTSTART:20260805T090000Z\r\n" +
"DTEND:20260805T100000Z\r\n" +
"SUMMARY:one off\r\n" +
"END:VEVENT\r\n" +
"END:VCALENDAR\r\n"
ev := mustParseICS(t, raw)
loc := time.UTC
gridStart := time.Date(2026, 8, 1, 0, 0, 0, 0, loc)
gridEnd := time.Date(2026, 8, 31, 0, 0, 0, 0, loc)
dayIndex := make(map[string]int)
for d, i := gridStart, 0; !d.After(gridEnd); d, i = d.AddDate(0, 0, 1), i+1 {
dayIndex[d.Format(dateLayout)] = i
}
got := eventOccurrenceDays(ev, gridStart, gridEnd, loc, dayIndex)
if len(got) != 1 {
t.Fatalf("expected exactly 1 occurrence day, got %d (%v)", len(got), got)
}
if _, ok := got[dayIndex["2026-08-05"]]; !ok {
t.Errorf("expected event on 2026-08-05, got days %v", got)
}
}
// seriesID returns the stable ID that addICSEvents/icsEventForDisplay
// advertise for the (single) series in raw — GroupSeries(raw)[0].ID().
// ICS-subscription grid cells and detail links are keyed by the series,
// not by individual VEVENTs, so this is what the href/ID in assertions
// must match.
func seriesID(t *testing.T, raw string) string {
t.Helper()
_ = t
cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode()
if err != nil {
t.Fatalf("seriesID: ical decode: %v", err)
}
series := icssub.GroupSeries(cal)
if len(series) == 0 {
t.Fatal("seriesID: no series")
}
return series[0].ID()
}
// mustParseICS parses raw and returns its first VEVENT (panic on error).
func mustParseICS(t *testing.T, raw string) ical.Event {
t.Helper()