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("404 Not Found")); 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") } }