package icssub import ( "net/http" "net/http/httptest" "strings" "sync" "sync/atomic" "testing" "time" ical "github.com/emersion/go-ical" ) const sampleICS = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:ev1@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260805T090000Z\r\n" + "DTEND:20260805T100000Z\r\n" + "SUMMARY:Original title\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" func mustParseEvent(raw string) ical.Event { cal, err := ical.NewDecoder(strings.NewReader(raw)).Decode() if err != nil { panic("mustParseEvent: " + err.Error()) } evs := cal.Events() if len(evs) == 0 { panic("mustParseEvent: no events") } return evs[0] } // TestCacheFirstFetchPopulatesEntry verifies that the very first Get for a // URL does a foreground fetch and returns the parsed calendar. func TestCacheFirstFetchPopulatesEntry(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(sampleICS)) })) defer srv.Close() c := NewCache(time.Hour) cal, err := c.Get(srv.URL) if err != nil { t.Fatalf("first Get: %v", err) } if len(cal.Events()) == 0 { t.Fatalf("expected >=1 event, got 0") } } // TestCacheStaleReturnsWithBackgroundRefresh verifies that once a cached // entry is stale, Get keeps returning the *stale* copy immediately while a // background refresh is spawned in a separate goroutine (rather than // blocking the caller on the network). func TestCacheStaleReturnsWithBackgroundRefresh(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) time.Sleep(50 * time.Millisecond) // make the fetch slow enough to w.WriteHeader(http.StatusOK) // observe as background work _, _ = w.Write([]byte(sampleICS)) })) defer srv.Close() c := NewCache(1 * time.Millisecond) // very short TTL if _, err := c.Get(srv.URL); err != nil { t.Fatalf("first Get: %v", err) } firstHits := hits.Load() // Force staleness. time.Sleep(3 * time.Millisecond) // Subsequent Get must return the stale copy immediately without waiting // for the slow server to respond — if it blocked, this call would take // >= 50ms, which we bound with a deadline below via the hit counter. st := time.Now() cal, err := c.Get(srv.URL) if err != nil { t.Fatalf("second Get: %v", err) } if len(cal.Events()) == 0 { t.Fatalf("expected event in stale response") } if time.Since(st) > 40*time.Millisecond { t.Fatalf("second Get appears to have blocked on the network for %v", time.Since(st)) } // Wait for the background refresh to complete. deadline := time.Now().Add(2 * time.Second) for hits.Load() == firstHits && time.Now().Before(deadline) { time.Sleep(5 * time.Millisecond) } if hits.Load() == firstHits { t.Fatalf("background refresh did not fire (hits stayed at %d)", firstHits) } } // TestCacheConcurrentFirstFetchCoalesce ensures N concurrent first calls // for the same URL coalesce to a small number of actual origin fetches. func TestCacheConcurrentFirstFetchCoalesce(t *testing.T) { var hits atomic.Int32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits.Add(1) time.Sleep(40 * time.Millisecond) // widen the coalescing window w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(sampleICS)) })) defer srv.Close() c := NewCache(time.Hour) const n = 8 errs := make([]error, n) var wg sync.WaitGroup start := make(chan struct{}) for i := 0; i < n; i++ { wg.Add(1) go func(i int) { defer wg.Done() <-start _, err := c.Get(srv.URL) errs[i] = err }(i) } close(start) wg.Wait() for _, err := range errs { if err != nil { t.Fatalf("goroutine error: %v", err) } } // Without coalescing we'd see n=8 hits; with it we expect a small // constant (the first caller fetches; the rest either return the // in-progress result or kick off a coalesced background refresh). if hits.Load() > 3 { t.Fatalf("expected coalescing to reduce fetches (got %d)", hits.Load()) } } // TestEventIDStable verifies that the same event hashes to the same ID // across calls, and is the right shape (32 hex chars + ".ics"). func TestEventIDStable(t *testing.T) { ev := mustParseEvent(sampleICS) id1 := EventID(ev) id2 := EventID(ev) if id1 != id2 { t.Fatalf("EventID not stable: %q vs %q", id1, id2) } if !strings.HasSuffix(id1, ".ics") { t.Fatalf("EventID should end in .ics, got %q", id1) } if len(id1) != 32+4 { t.Fatalf("expected 32 hex chars + .ics, got %q (len %d)", id1, len(id1)) } } // TestEventIDDiffersForDifferentEvents verifies that two events with // different titles or different start times hash to different IDs, while // two events with only a different UID hash to the same ID. func TestEventIDDiffersForDifferentEvents(t *testing.T) { base := EventID(mustParseEvent(sampleICS)) // Different title. rawA := strings.Replace(sampleICS, "SUMMARY:Original title", "SUMMARY:Other title", 1) if EventID(mustParseEvent(rawA)) == base { t.Fatal("same start, different title must hash differently") } // Different start time. rawB := strings.Replace(sampleICS, "DTSTART:20260805T090000Z", "DTSTART:20260806T090000Z", 1) if EventID(mustParseEvent(rawB)) == base { t.Fatal("different start times must hash differently") } // Same start, same title, different UID → same hash. rawC := strings.Replace(sampleICS, "UID:ev1@nidus.test", "UID:ev2@nidus.test", 1) if EventID(mustParseEvent(rawC)) != base { t.Fatal("UID should not affect the hash — same start/dur/title must be equal") } } // TestEventIDWorksWithoutUID verifies EventID still produces a value (from // DTSTART+DTEND+SUMMARY) when the event has no UID property. func TestEventIDWorksWithoutUID(t *testing.T) { raw := strings.Replace(sampleICS, "UID:ev1@nidus.test\r\n", "", 1) ev := mustParseEvent(raw) id := EventID(ev) if id == "" { t.Fatal("EventID should be non-empty even without UID") } } // outlookStyleSeries mimics the canonical Outlook/Exchange shape: one UID // carrying a base VEVENT (with RRULE) plus bare per-occurrence instance // VEVENTs (the "fully expanded" ICS model). GroupSeries must fold all of // them into ONE Series (base + 2 instances), not two separate entities. const outlookStyleSeries = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:series@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260306T103000Z\r\n" + "DTEND:20260306T113000Z\r\n" + "RRULE:FREQ=WEEKLY;UNTIL=20260903T083000Z;INTERVAL=3;BYDAY=FR;WKST=SU\r\n" + "SUMMARY:#smarttouch Planning\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\n" + "UID:series@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260320T103000Z\r\n" + "DTEND:20260320T113000Z\r\n" + "SUMMARY:#smarttouch Planning\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\n" + "UID:series@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260403T103000Z\r\n" + "DTEND:20260403T123000Z\r\n" + "SUMMARY:#smarttouch Planning (extended)\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" func TestGroupSeriesFoldsOutlookSeriesShape(t *testing.T) { cal, err := ical.NewDecoder(strings.NewReader(outlookStyleSeries)).Decode() if err != nil { t.Fatal(err) } if got := len(cal.Events()); got != 3 { t.Fatalf("fixture must have 3 VEVENTs, got %d", got) } series := GroupSeries(cal) if len(series) != 1 { t.Fatalf("GroupSeries: expected 1 series, got %d", len(series)) } s := series[0] if s.Base == nil { t.Fatal("the base (RRULE) VEVENT must be the series Base") } if s.Base.Props.Get(ical.PropUID).Value != "series@nidus.test" { t.Fatalf("wrong base UID: %q", s.Base.Props.Get(ical.PropUID).Value) } if len(s.Instances) != 2 { t.Fatalf("expected 2 explicit instances, got %d", len(s.Instances)) } if s.ID() == "" { t.Fatal("series ID must be non-empty") } // Anchor must be the base. if got := s.Anchor().Props.Get(ical.PropUID).Value; got != "series@nidus.test" { t.Fatalf("Anchor must be the base, got UID %q", got) } // Calendar() must contain all three VEVENTs. if got := s.Calendar().Events(); len(got) != 3 { t.Fatalf("Calendar() must contain 3 events, got %d", len(got)) } } // tzAwareSeries mimics a real Outlook/Exchange feed (already run through // icalfix.NormalizeTimeZones, hence "Europe/Berlin" rather than the raw // Windows zone name): a VCALENDAR with one VTIMEZONE the events actually // reference, plus a second, unrelated VTIMEZONE no event references. const tzAwareSeries = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VTIMEZONE\r\n" + "TZID:Europe/Berlin\r\n" + "END:VTIMEZONE\r\n" + "BEGIN:VTIMEZONE\r\n" + "TZID:America/New_York\r\n" + "END:VTIMEZONE\r\n" + "BEGIN:VEVENT\r\n" + "UID:tz-series@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART;TZID=Europe/Berlin:20260910T100000\r\n" + "DTEND;TZID=Europe/Berlin:20260910T110000\r\n" + "RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=WE\r\n" + "SUMMARY:Frontend Weekly\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" // TestSeriesCalendarEmbedsReferencedTimezone is the regression test for // the bug report "Frontend Weekly / Abstimmung Next-Release Package / // Prd-Deployment don't show up in the CalDAV client": Series.Calendar() // used to build a VTIMEZONE-less VCALENDAR for every ICS-subscription // series, even though its VEVENTs' DTSTART/DTEND still carried a // TZID=Europe/Berlin parameter — an RFC 5545 §3.6.5 violation that made // strict clients (notably ical4j, which DAVx5 is built on) fail to // resolve the timezone for recurrence-rule expansion, silently dropping // the whole (recurring) VEVENT from sync. The fix must embed exactly the // VTIMEZONE(s) the series' own VEVENTs reference — no more, no less. func TestSeriesCalendarEmbedsReferencedTimezone(t *testing.T) { cal, err := ical.NewDecoder(strings.NewReader(tzAwareSeries)).Decode() if err != nil { t.Fatal(err) } series := GroupSeries(cal) if len(series) != 1 { t.Fatalf("expected 1 series, got %d", len(series)) } out := series[0].Calendar() var tzids []string for _, child := range out.Children { if child.Name == ical.CompTimezone { tzids = append(tzids, child.Props.Get(ical.PropTimezoneID).Value) } } if len(tzids) != 1 || tzids[0] != "Europe/Berlin" { t.Fatalf("expected exactly the referenced VTIMEZONE (Europe/Berlin) to be embedded, got %v", tzids) } // Re-encoding and re-decoding must round-trip: the VEVENT's DTSTART // TZID must resolve to a VTIMEZONE actually present in the same // object (this is what a strict client like ical4j checks). if got := len(out.Events()); got != 1 { t.Fatalf("expected 1 VEVENT in the series calendar, got %d", got) } } // twoDistinctSeries are two completely unrelated events (different UIDs). // GroupSeries must NOT fold them together. const twoDistinctSeries = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:a@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260809T090000Z\r\n" + "DTEND:20260809T100000Z\r\n" + "SUMMARY:meeting A\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\n" + "UID:b@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260809T110000Z\r\n" + "DTEND:20260809T120000Z\r\n" + "SUMMARY:meeting B\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" func TestGroupSeriesKeepsDistinctSeries(t *testing.T) { cal, err := ical.NewDecoder(strings.NewReader(twoDistinctSeries)).Decode() if err != nil { t.Fatal(err) } if got := len(GroupSeries(cal)); got != 2 { t.Fatalf("two distinct UIDs must produce two series, got %d", got) } } // seriesOnlyInstances is the shape of a set of one-off events on one UID // with no RRULE. The earliest instance must anchor the series. const seriesOnlyInstances = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\n" + "PRODID:-//nidus//test//EN\r\n" + "BEGIN:VEVENT\r\n" + "UID:g@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260810T090000Z\r\n" + "DTEND:20260810T100000Z\r\n" + "SUMMARY:later\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\n" + "UID:g@nidus.test\r\n" + "DTSTAMP:20260101T000000Z\r\n" + "DTSTART:20260809T090000Z\r\n" + "DTEND:20260809T100000Z\r\n" + "SUMMARY:earlier\r\n" + "END:VEVENT\r\n" + "END:VCALENDAR\r\n" func TestGroupSeriesAnchorWithoutBase(t *testing.T) { cal, err := ical.NewDecoder(strings.NewReader(seriesOnlyInstances)).Decode() if err != nil { t.Fatal(err) } series := GroupSeries(cal) if len(series) != 1 { t.Fatalf("expected 1 series, got %d", len(series)) } s := series[0] if s.Base != nil { t.Fatalf("no event carries RRULE, so Base must be nil, got %v", s.Base) } if len(s.Instances) != 2 { t.Fatalf("expected 2 instances, got %d", len(s.Instances)) } anchor := s.Anchor() if anchor == nil { t.Fatal("Anchor must be non-nil for a non-empty series") } sp := anchor.Props.Get(ical.PropSummary) if sp == nil || sp.Value != "earlier" { t.Fatalf("Anchor should be the earliest instance, got %v", sp) } } // TestSeriesOccurrencesInReturnsOneDayPerDay verifies the crux of the // web-grid fix: on a day the RRULE base covers AND an explicit instance // covers, OccurrencesIn returns exactly one event for that day (the // instance, which "wins" per the Exchange override model), and does not // double-paint. func TestSeriesOccurrencesInReturnsOneDayPerDay(t *testing.T) { cal, err := ical.NewDecoder(strings.NewReader(outlookStyleSeries)).Decode() if err != nil { t.Fatal(err) } series := GroupSeries(cal) if len(series) != 1 { t.Fatalf("expected 1 series, got %d", len(series)) } s := series[0] loc := time.UTC // Window that contains 2026-03-20 (which is both an RRULE date and a // separate explicit instance in the fixture). gs := time.Date(2026, 3, 1, 0, 0, 0, 0, loc) ge := time.Date(2026, 4, 10, 0, 0, 0, 0, loc) occ := s.OccurrencesIn(gs, ge, loc) if len(occ) < 3 { t.Fatalf("expected at least 3 days of occurrences, got %d: %v", len(occ), keysOf(occ)) } // The explicit instance on 2026-03-20 (title "#smarttouch Planning") // must be the representative event for that day (the base also covers // that day via RRULE — but the instance wins). evOnMar20, ok := occ["2026-03-20"] if !ok { t.Fatalf("2026-03-20 is in the window AND is an explicit instance — must be present, got %v", keysOf(occ)) } if p := evOnMar20.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning" { t.Fatalf("instance on 2026-03-20 should win over the base; got %v", p) } // 2026-04-03 has an explicit instance with a different title — should // be that title (not the base title) for that day. evOnApr3, ok := occ["2026-04-03"] if !ok { t.Fatalf("2026-04-03 must be present, got %v", keysOf(occ)) } if p := evOnApr3.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning (extended)" { t.Fatalf("instance on 2026-04-03 should have the overridden title; got %v", p) } // 2026-03-06 is the RRULE base's DTSTART and NOT explicitly overridden — // the base should be the representative for that day. evOnBase, ok := occ["2026-03-06"] if !ok { t.Fatalf("2026-03-06 (base DTSTART) must be present, got %v", keysOf(occ)) } if p := evOnBase.Props.Get(ical.PropSummary); p == nil || p.Value != "#smarttouch Planning" { t.Fatalf("base should represent 2026-03-06; got %v", p) } } func keysOf(m map[string]ical.Event) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } return out }