oficina / calendar Public

Clone
git clone https://amadan.net/oficina/calendar

Plain git — no account needed to clone.

Download

Download this file

1package calendartest
2
3import (
4 "strings"
5 "testing"
6
7 calendar "amadan.net/oficina/calendar/internal/calendar"
8)
9
10// A file this app wrote must read back as what it meant. The round trip
11// is the only test that catches an escape applied in the wrong order or
12// a fold that split a rune.
13func TestICSRoundTrip(t *testing.T) {
14 events := []calendar.EventView{{
15 Event: calendar.Event{
16 ID: 1, AllDay: false,
17 StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00",
18 TZ: "Europe/Dublin", Kind: calendar.KindMeeting,
19 Status: calendar.StatusConfirmed, Sequence: 3,
20 RRule: "FREQ=WEEKLY;BYDAY=WE",
21 },
22 // Every character the RFC escapes, in one title, plus a
23 // non-ASCII rune so a fold that split bytes shows up.
24 Title: `Budget; review, "phase 2" \ café — año`,
25 Location: "Room 3, upstairs; past the kitchen",
26 Notes: "First line\nSecond line",
27 UIDSealedOpen: "abc123@calendar.oficina",
28 }}
29
30 out := calendar.WriteICS("Team", events, false)
31
32 if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
33 t.Error("the stream does not begin with VCALENDAR and CRLF")
34 }
35 if !strings.Contains(out, "DTSTART;TZID=Europe/Dublin:20260902T0930") {
36 t.Errorf("DTSTART is missing its TZID:\n%s", out)
37 }
38 if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=WE") {
39 t.Error("the rule did not survive export")
40 }
41
42 parsed, res, err := calendar.ParseICS(strings.NewReader(out), "UTC")
43 if err != nil {
44 t.Fatalf("ParseICS: %v", err)
45 }
46 if len(parsed) != 1 {
47 t.Fatalf("round trip produced %d events, want 1 (skipped %d)", len(parsed), res.Skipped)
48 }
49 got := parsed[0]
50
51 if got.Title != events[0].Title {
52 t.Errorf("title round-tripped as %q, want %q", got.Title, events[0].Title)
53 }
54 if got.Location != events[0].Location {
55 t.Errorf("location round-tripped as %q, want %q", got.Location, events[0].Location)
56 }
57 if got.Notes != events[0].Notes {
58 t.Errorf("notes round-tripped as %q, want %q", got.Notes, events[0].Notes)
59 }
60 if got.StartLocal != "2026-09-02T09:30" {
61 t.Errorf("start round-tripped as %q", got.StartLocal)
62 }
63 if got.TZ != "Europe/Dublin" {
64 t.Errorf("zone round-tripped as %q", got.TZ)
65 }
66 if got.RRule != "FREQ=WEEKLY;BYDAY=WE" {
67 t.Errorf("rule round-tripped as %q", got.RRule)
68 }
69}
70
71// A long line is folded at 75 octets and continued with a space. The
72// fold must never split a multi-byte rune, or the file stops being
73// valid UTF-8 and importers reject it in ways that look like a corrupt
74// calendar.
75func TestLongLinesFoldWithoutBreakingRunes(t *testing.T) {
76 long := strings.Repeat("café ", 40) // 200 runes, 240 bytes
77 out := calendar.WriteICS("Team", []calendar.EventView{{
78 Event: calendar.Event{
79 ID: 1, StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00",
80 TZ: "UTC", Kind: calendar.KindMeeting, Status: calendar.StatusConfirmed,
81 },
82 Title: long,
83 }}, false)
84
85 for _, line := range strings.Split(out, "\r\n") {
86 if len(line) > 75 {
87 t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line)
88 }
89 }
90 if !strings.Contains(out, "\r\n ") {
91 t.Error("nothing was folded, so the fold was not exercised")
92 }
93 parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC")
94 if err != nil {
95 t.Fatalf("ParseICS: %v", err)
96 }
97 if len(parsed) != 1 || parsed[0].Title != long {
98 t.Error("a folded title did not unfold back to itself")
99 }
100}
101
102// An all-day event is a DATE, and must stay one. Exported as a datetime
103// at midnight it lands on the wrong day for everybody west of
104// Greenwich.
105func TestAllDayExportsAsADate(t *testing.T) {
106 out := calendar.WriteICS("Team", []calendar.EventView{{
107 Event: calendar.Event{
108 ID: 1, AllDay: true,
109 StartLocal: "2026-09-02", EndLocal: "2026-09-03",
110 Kind: calendar.KindNote, Status: calendar.StatusConfirmed,
111 },
112 Title: "Somebody's birthday",
113 }}, false)
114
115 if !strings.Contains(out, "DTSTART;VALUE=DATE:20260902") {
116 t.Errorf("an all-day event did not export as a DATE:\n%s", out)
117 }
118 if strings.Contains(out, "DTSTART;VALUE=DATE:20260902T") {
119 t.Error("an all-day DTSTART carries a time")
120 }
121
122 parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC")
123 if err != nil || len(parsed) != 1 {
124 t.Fatalf("ParseICS: %v (%d events)", err, len(parsed))
125 }
126 if !parsed[0].AllDay {
127 t.Error("an all-day event round-tripped as timed")
128 }
129 if parsed[0].StartLocal != "2026-09-02" {
130 t.Errorf("all-day start round-tripped as %q", parsed[0].StartLocal)
131 }
132}
133
134// A file from another calendar must import as much as it honestly can,
135// and report the rest. This is a Google-shaped export with a foreign
136// rule, an attendee, and a VTIMEZONE block that must not leak its own
137// properties onto the event.
138func TestImportingAForeignFile(t *testing.T) {
139 const file = "BEGIN:VCALENDAR\r\n" +
140 "VERSION:2.0\r\nPRODID:-//Example//EN\r\n" +
141 "BEGIN:VTIMEZONE\r\nTZID:Europe/Dublin\r\n" +
142 "BEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nTZOFFSETFROM:+0100\r\n" +
143 "TZOFFSETTO:+0000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" +
144 "BEGIN:VEVENT\r\nUID:x1@example.com\r\nSUMMARY:Retro\r\n" +
145 "DTSTART;TZID=Europe/Dublin:20260902T140000\r\n" +
146 "DTEND;TZID=Europe/Dublin:20260902T150000\r\n" +
147 "RRULE:FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR\r\n" +
148 "ATTENDEE;CN=Someone:mailto:someone@example.com\r\n" +
149 "END:VEVENT\r\n" +
150 "BEGIN:VEVENT\r\nUID:x2@example.com\r\nSUMMARY:Floating thing\r\n" +
151 "DTSTART:20260903T110000\r\nDTEND:20260903T120000\r\nEND:VEVENT\r\n" +
152 "END:VCALENDAR\r\n"
153
154 parsed, res, err := calendar.ParseICS(strings.NewReader(file), "UTC")
155 if err != nil {
156 t.Fatalf("ParseICS: %v", err)
157 }
158 if len(parsed) != 2 {
159 t.Fatalf("imported %d events, want 2", len(parsed))
160 }
161
162 // The VTIMEZONE's own DTSTART must not have landed on the event.
163 if parsed[0].StartLocal != "2026-09-02T14:00" {
164 t.Errorf("the first event's start is %q — a VTIMEZONE property leaked onto it", parsed[0].StartLocal)
165 }
166 if parsed[0].TZ != "Europe/Dublin" {
167 t.Errorf("zone imported as %q", parsed[0].TZ)
168 }
169
170 // A rule outside the subset is KEPT, not dropped, and counted.
171 if !strings.Contains(parsed[0].RRule, "BYSETPOS") {
172 t.Error("a foreign rule was dropped rather than stored verbatim")
173 }
174 if res.ForeignRules != 1 {
175 t.Errorf("ForeignRules = %d, want 1", res.ForeignRules)
176 }
177 // Guests are not modelled, and the count says so rather than the
178 // lines vanishing silently.
179 if res.Attendees != 1 {
180 t.Errorf("Attendees = %d, want 1", res.Attendees)
181 }
182 // A zoneless event stays floating rather than being guessed into
183 // the importer's zone.
184 if parsed[1].TZ != "" {
185 t.Errorf("a floating event was given zone %q", parsed[1].TZ)
186 }
187 if res.Floating != 1 {
188 t.Errorf("Floating = %d, want 1", res.Floating)
189 }
190}
191
192// A VEVENT with no DTEND takes the RFC's defaults rather than being
193// skipped.
194func TestImportAppliesDurationAndImpliedEnd(t *testing.T) {
195 const file = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" +
196 "UID:d1\r\nSUMMARY:With a duration\r\n" +
197 "DTSTART;TZID=UTC:20260902T090000\r\nDURATION:PT1H30M\r\n" +
198 "END:VEVENT\r\nBEGIN:VEVENT\r\n" +
199 "UID:d2\r\nSUMMARY:A whole day\r\n" +
200 "DTSTART;VALUE=DATE:20260902\r\n" +
201 "END:VEVENT\r\nEND:VCALENDAR\r\n"
202
203 parsed, _, err := calendar.ParseICS(strings.NewReader(file), "UTC")
204 if err != nil || len(parsed) != 2 {
205 t.Fatalf("ParseICS: %v (%d events)", err, len(parsed))
206 }
207 if parsed[0].EndLocal != "2026-09-02T10:30" {
208 t.Errorf("DURATION:PT1H30M gave end %q, want 2026-09-02T10:30", parsed[0].EndLocal)
209 }
210 // An all-day event with no end is one day, and the end is
211 // exclusive — so the following date.
212 if parsed[1].EndLocal != "2026-09-03" {
213 t.Errorf("an all-day event with no DTEND ends %q, want 2026-09-03", parsed[1].EndLocal)
214 }
215}
216