diff options
| author | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 07:57:36 +0000 |
|---|---|---|
| committer | Peter Stone <thepeterstone@gmail.com> | 2026-09-20 07:57:36 +0000 |
| commit | 4d160762aeef531208f8cf6094a0e58a9e7d53ec (patch) | |
| tree | 79584994808a38a2518ad986dc739daf720d3d77 /internal/api/ics_test.go | |
| parent | c5ab2f9a7f6a0b1e456e13934268d1509504c55a (diff) | |
Add .ics calendar feed support
doot reads Google Calendar through a service account, by calendar ID. That
cannot consume an .ics URL, and it cannot see a calendar subscribed to inside a
personal Google account either, because Google does not allow re-sharing a
subscribed calendar. Published schedules — sports fixtures, school terms, bin
collections — are distributed almost exclusively as .ics, so none of them could
be added at all.
Feeds are now fetched on the normal calendar refresh and merged into
calendar_events alongside Google events, so the timeline, widget and everything
downstream keep seeing one list and needed no changes.
Failure handling is deliberately asymmetric. A Google error is still returned as
an error, unchanged, so an outage there surfaces. A single failing feed is
recorded against that feed and skipped, because one dead URL should not take the
whole calendar down — and last_error is shown in settings, since a feed that has
quietly stopped resolving otherwise looks identical to a team with no games
left.
The parser handles what real feeds actually contain: line unfolding, CRLF and
LF, UTC / TZID / VALUE=DATE times, and escaped text. Two details worth naming:
a TZID time is wall-clock in that zone, and reading it as UTC would put a 1:15pm
Pacific kickoff on the timeline at 1:15pm local — wrong by the offset but
entirely plausible-looking. And non-calendar content is a hard error rather than
zero events, because an HTML error page from a moved feed URL is the common
failure and "no events" reads as success.
RRULE is deliberately unsupported: every schedule feed this targets expands
recurrence itself, one VEVENT per fixture, and a correct expansion is a large
amount of subtle code. A recurring event appears once rather than wrongly.
Settings gains an add/enable/disable/remove UI. webcal:// links are accepted and
rewritten, since that is how feeds are usually advertised.
16 parser tests, 5 store tests. Verified against the real Seattle Seahawks feed:
21 events, all with valid times.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018AwnALZL6bD4VBtATDVv3B
Diffstat (limited to 'internal/api/ics_test.go')
| -rw-r--r-- | internal/api/ics_test.go | 200 |
1 files changed, 200 insertions, 0 deletions
diff --git a/internal/api/ics_test.go b/internal/api/ics_test.go new file mode 100644 index 0000000..a4a3474 --- /dev/null +++ b/internal/api/ics_test.go @@ -0,0 +1,200 @@ +package api + +import ( + "strings" + "testing" + "time" +) + +// A realistic feed: UTC stamps, a TZID-qualified local time, an all-day event, +// folded lines, escaped text, and CRLF endings — all of which appear in the +// sports-schedule feeds this was written for. +const sampleICS = "BEGIN:VCALENDAR\r\n" + + "VERSION:2.0\r\n" + + "PRODID:-//Test//EN\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:game-1@example.com\r\n" + + "DTSTART:20260913T201500Z\r\n" + + "DTEND:20260913T231500Z\r\n" + + "SUMMARY:Seahawks at 49ers\r\n" + + "LOCATION:Levi's Stadium\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:game-2@example.com\r\n" + + "DTSTART;TZID=America/Los_Angeles:20260920T131500\r\n" + + "DTEND;TZID=America/Los_Angeles:20260920T161500\r\n" + + "SUMMARY:Seahawks vs. Saints\r\n" + + "DESCRIPTION:Home opener\\, kickoff 1:15pm\\nGates open at 11am\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:bye-week@example.com\r\n" + + "DTSTART;VALUE=DATE:20261012\r\n" + + "DTEND;VALUE=DATE:20261013\r\n" + + "SUMMARY:Bye week\r\n" + + "END:VEVENT\r\n" + + "BEGIN:VEVENT\r\n" + + "UID:folded@example.com\r\n" + + "DTSTART:20261019T170000Z\r\n" + + "DTEND:20261019T200000Z\r\n" + + // Folded mid-word on purpose: unfolding removes the CRLF and exactly one + // leading space, so "...that the fe" + " " + "ed has..." must rejoin as + // "that the feed has...". Folding at a word boundary would not distinguish + // a correct implementation from one that also eats a real space. + "SUMMARY:A very long game title that the fe\r\n" + + " ed has folded across two lines\r\n" + + "END:VEVENT\r\n" + + "END:VCALENDAR\r\n" + +func parseOrFail(t *testing.T, body string) []ICSEvent { + t.Helper() + events, err := ParseICS(strings.NewReader(body)) + if err != nil { + t.Fatalf("ParseICS: %v", err) + } + return events +} + +func TestParseICS_ReadsAllEvents(t *testing.T) { + events := parseOrFail(t, sampleICS) + if len(events) != 4 { + t.Fatalf("got %d events, want 4: %+v", len(events), events) + } +} + +func TestParseICS_UTCTimestamps(t *testing.T) { + e := parseOrFail(t, sampleICS)[0] + if e.UID != "game-1@example.com" { + t.Errorf("UID = %q", e.UID) + } + if e.Summary != "Seahawks at 49ers" { + t.Errorf("Summary = %q", e.Summary) + } + want := time.Date(2026, 9, 13, 20, 15, 0, 0, time.UTC) + if !e.Start.Equal(want) { + t.Errorf("Start = %v, want %v", e.Start.UTC(), want) + } + if !e.End.Equal(time.Date(2026, 9, 13, 23, 15, 0, 0, time.UTC)) { + t.Errorf("End = %v", e.End.UTC()) + } + if e.AllDay { + t.Error("a timed event was marked all-day") + } +} + +// A TZID-qualified time is a wall-clock time in that zone, not UTC. Reading it +// as UTC would put a 1:15pm Pacific kickoff on the timeline at 1:15pm local — +// silently, and only wrong by the offset. +func TestParseICS_TZIDIsInterpretedInThatZone(t *testing.T) { + e := parseOrFail(t, sampleICS)[1] + la, err := time.LoadLocation("America/Los_Angeles") + if err != nil { + t.Skipf("tzdata unavailable: %v", err) + } + want := time.Date(2026, 9, 20, 13, 15, 0, 0, la) + if !e.Start.Equal(want) { + t.Errorf("Start = %v (%v), want %v", e.Start, e.Start.UTC(), want.UTC()) + } +} + +func TestParseICS_AllDayEvent(t *testing.T) { + e := parseOrFail(t, sampleICS)[2] + if !e.AllDay { + t.Error("VALUE=DATE event should be all-day") + } + if e.Start.Year() != 2026 || e.Start.Month() != time.October || e.Start.Day() != 12 { + t.Errorf("Start = %v, want 2026-10-12", e.Start) + } +} + +// Folded lines: a leading space or tab continues the previous line. Not +// unfolding leaves the title truncated at the fold. +func TestParseICS_UnfoldsLines(t *testing.T) { + e := parseOrFail(t, sampleICS)[3] + want := "A very long game title that the feed has folded across two lines" + if e.Summary != want { + t.Errorf("Summary = %q, want %q", e.Summary, want) + } +} + +// Text values escape commas, semicolons, backslashes and newlines. +func TestParseICS_UnescapesText(t *testing.T) { + e := parseOrFail(t, sampleICS)[1] + want := "Home opener, kickoff 1:15pm\nGates open at 11am" + if e.Description != want { + t.Errorf("Description = %q, want %q", e.Description, want) + } +} + +func TestParseICS_LocationIsCaptured(t *testing.T) { + if got := parseOrFail(t, sampleICS)[0].Location; got != "Levi's Stadium" { + t.Errorf("Location = %q", got) + } +} + +// An event with no DTEND gets a sensible end rather than a zero time, which +// would render as a 56-year-long event. +func TestParseICS_MissingDTENDDefaults(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No end\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + e := parseOrFail(t, body)[0] + if !e.End.After(e.Start) { + t.Errorf("End %v should be after Start %v", e.End, e.Start) + } +} + +// Events with no usable start are skipped rather than landing at the zero time, +// where they would sort to the top of the timeline forever. +func TestParseICS_SkipsEventsWithoutAStart(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nUID:x\r\nSUMMARY:No start\r\nEND:VEVENT\r\n" + + "BEGIN:VEVENT\r\nUID:y\r\nDTSTART:20260913T201500Z\r\nSUMMARY:Fine\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + events := parseOrFail(t, body) + if len(events) != 1 || events[0].Summary != "Fine" { + t.Errorf("got %+v, want only the event with a start", events) + } +} + +func TestParseICS_HandlesLFOnlyLineEndings(t *testing.T) { + body := strings.ReplaceAll(sampleICS, "\r\n", "\n") + if got := len(parseOrFail(t, body)); got != 4 { + t.Errorf("got %d events with LF endings, want 4", got) + } +} + +// Anything that is not a calendar must be a clear error, not silently zero +// events — an HTML error page from a dead feed URL is the common case. +func TestParseICS_RejectsNonCalendarContent(t *testing.T) { + if _, err := ParseICS(strings.NewReader("<html><body>404 Not Found</body></html>")); err == nil { + t.Error("expected an error for non-calendar content") + } +} + +func TestParseICS_EmptyCalendarIsNotAnError(t *testing.T) { + events, err := ParseICS(strings.NewReader("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nEND:VCALENDAR\r\n")) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(events) != 0 { + t.Errorf("got %d events, want 0", len(events)) + } +} + +// A feed with no UID still needs a stable id, or every refresh would look like +// a brand-new set of events. +func TestICSEvent_StableIDWithoutUID(t *testing.T) { + body := "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDTSTART:20260913T201500Z\r\nSUMMARY:No UID\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + first := parseOrFail(t, body)[0].StableID("feed-1") + second := parseOrFail(t, body)[0].StableID("feed-1") + if first == "" { + t.Fatal("StableID returned empty") + } + if first != second { + t.Errorf("StableID is not stable: %q vs %q", first, second) + } +} + +// Two feeds containing the same UID must not collide into one event. +func TestICSEvent_StableIDIsScopedToItsFeed(t *testing.T) { + e := parseOrFail(t, sampleICS)[0] + if e.StableID("feed-1") == e.StableID("feed-2") { + t.Error("same UID in different feeds produced the same id") + } +} |
