package icalfix import ( "bytes" "testing" ical "github.com/emersion/go-ical" ) // TestNormalizeTimeZonesFixesRecurringEvent reproduces the bug reported by // a DAVx5 client: a recurring VEVENT with a Windows-style TZID // ("W. Europe Standard Time") decodes fine, but expanding its recurrence // via RecurrenceSet (as go-webdav's caldav.Filter does for a time-range // REPORT) fails with "unknown time zone" because go-ical resolves TZID // via a plain time.LoadLocation call. Normalizing the TZID to its IANA // equivalent before decoding must make that expansion succeed. func TestNormalizeTimeZonesFixesRecurringEvent(t *testing.T) { const raw = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//Test//Test//EN\r\n" + "BEGIN:VTIMEZONE\r\n" + "TZID:W. Europe Standard Time\r\n" + "END:VTIMEZONE\r\n" + "BEGIN:VEVENT\r\n" + "UID:recurring@example.com\r\n" + "DTSTAMP:20240101T000000Z\r\n" + "DTSTART;TZID=W. Europe Standard Time:20260522T220000\r\n" + "DTEND;TZID=W. Europe Standard Time:20260522T230000\r\n" + "RRULE:FREQ=WEEKLY;COUNT=5\r\n" + "SUMMARY:Weekly sync\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" normalized := NormalizeTimeZones([]byte(raw)) cal, err := ical.NewDecoder(bytes.NewReader(normalized)).Decode() if err != nil { t.Fatalf("decoding normalized calendar: %v", err) } events := cal.Events() if len(events) != 1 { t.Fatalf("expected 1 event, got %d", len(events)) } if _, err := events[0].RecurrenceSet(nil); err != nil { t.Fatalf("expanding recurrence after normalization: %v", err) } // The un-normalized original must reproduce the reported failure, so // this test actually demonstrates the fix rather than trivially // passing regardless. origCal, err := ical.NewDecoder(bytes.NewReader([]byte(raw))).Decode() if err != nil { t.Fatalf("decoding original calendar: %v", err) } if _, err := origCal.Events()[0].RecurrenceSet(nil); err == nil { t.Fatalf("expected RecurrenceSet to fail on un-normalized Windows TZID, it didn't") } } func TestNormalizeTimeZonesLeavesUnknownAndIANAZonesAlone(t *testing.T) { const raw = "BEGIN:VCALENDAR\r\n" + "DTSTART;TZID=Europe/Berlin:20260522T220000\r\n" + "DTEND;TZID=Some/Unknown-Zone:20260522T230000\r\n" + "END:VCALENDAR\r\n" got := string(NormalizeTimeZones([]byte(raw))) if got != raw { t.Fatalf("expected data to be unchanged, got:\n%s", got) } } // TestNormalizeTimeZonesFixesVTIMEZONECRLFLine reproduces a real bug: the // standalone "TZID:" property line inside a VTIMEZONE component // (as opposed to a "TZID=" parameter on DTSTART/DTEND/etc.) was // never being normalized on real-world feeds, because every // Outlook/Exchange-published ICS uses CRLF line endings and Go's RE2 "$" // anchor in multi-line mode only matches immediately before a literal // "\n" — not before "\r\n". So on a line like // "TZID:W. Europe Standard Time\r\n", the old // `^TZID:([^\r\n]+)$` pattern never matched at all, leaving the // VTIMEZONE's own declared TZID un-normalized while every "TZID=..." // parameter elsewhere in the same object WAS normalized (that regex has // no such issue) — so nothing referencing the VTIMEZONE by name could // ever find it again. See internal/icssub.Series.referencedTimezones, // which depends on exactly this match to embed the right VTIMEZONE in a // synthetic per-series VCALENDAR. func TestNormalizeTimeZonesFixesVTIMEZONECRLFLine(t *testing.T) { const raw = "BEGIN:VCALENDAR\r\n" + "BEGIN:VTIMEZONE\r\n" + "TZID:W. Europe Standard Time\r\n" + "END:VTIMEZONE\r\n" + "END:VCALENDAR\r\n" const want = "BEGIN:VCALENDAR\r\n" + "BEGIN:VTIMEZONE\r\n" + "TZID:Europe/Berlin\r\n" + "END:VTIMEZONE\r\n" + "END:VCALENDAR\r\n" got := string(NormalizeTimeZones([]byte(raw))) if got != want { t.Fatalf("CRLF-terminated VTIMEZONE TZID line was not normalized:\ngot: %q\nwant: %q", got, want) } }