| 1 | package calendartest |
| 2 | |
| 3 | import ( |
| 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. |
| 13 | func 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. |
| 75 | func 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. |
| 105 | func 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. |
| 138 | func 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. |
| 194 | func 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 | |