1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
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")
}
}
|