package calendartest import ( "strings" "testing" calendar "amadan.net/oficina/calendar/internal/calendar" ) // A file this app wrote must read back as what it meant. The round trip // is the only test that catches an escape applied in the wrong order or // a fold that split a rune. func TestICSRoundTrip(t *testing.T) { events := []calendar.EventView{{ Event: calendar.Event{ ID: 1, AllDay: false, StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00", TZ: "Europe/Dublin", Kind: calendar.KindMeeting, Status: calendar.StatusConfirmed, Sequence: 3, RRule: "FREQ=WEEKLY;BYDAY=WE", }, // Every character the RFC escapes, in one title, plus a // non-ASCII rune so a fold that split bytes shows up. Title: `Budget; review, "phase 2" \ café — año`, Location: "Room 3, upstairs; past the kitchen", Notes: "First line\nSecond line", UIDSealedOpen: "abc123@calendar.oficina", }} out := calendar.WriteICS("Team", events, false) if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") { t.Error("the stream does not begin with VCALENDAR and CRLF") } if !strings.Contains(out, "DTSTART;TZID=Europe/Dublin:20260902T0930") { t.Errorf("DTSTART is missing its TZID:\n%s", out) } if !strings.Contains(out, "RRULE:FREQ=WEEKLY;BYDAY=WE") { t.Error("the rule did not survive export") } parsed, res, err := calendar.ParseICS(strings.NewReader(out), "UTC") if err != nil { t.Fatalf("ParseICS: %v", err) } if len(parsed) != 1 { t.Fatalf("round trip produced %d events, want 1 (skipped %d)", len(parsed), res.Skipped) } got := parsed[0] if got.Title != events[0].Title { t.Errorf("title round-tripped as %q, want %q", got.Title, events[0].Title) } if got.Location != events[0].Location { t.Errorf("location round-tripped as %q, want %q", got.Location, events[0].Location) } if got.Notes != events[0].Notes { t.Errorf("notes round-tripped as %q, want %q", got.Notes, events[0].Notes) } if got.StartLocal != "2026-09-02T09:30" { t.Errorf("start round-tripped as %q", got.StartLocal) } if got.TZ != "Europe/Dublin" { t.Errorf("zone round-tripped as %q", got.TZ) } if got.RRule != "FREQ=WEEKLY;BYDAY=WE" { t.Errorf("rule round-tripped as %q", got.RRule) } } // A long line is folded at 75 octets and continued with a space. The // fold must never split a multi-byte rune, or the file stops being // valid UTF-8 and importers reject it in ways that look like a corrupt // calendar. func TestLongLinesFoldWithoutBreakingRunes(t *testing.T) { long := strings.Repeat("café ", 40) // 200 runes, 240 bytes out := calendar.WriteICS("Team", []calendar.EventView{{ Event: calendar.Event{ ID: 1, StartLocal: "2026-09-02T09:30", EndLocal: "2026-09-02T10:00", TZ: "UTC", Kind: calendar.KindMeeting, Status: calendar.StatusConfirmed, }, Title: long, }}, false) for _, line := range strings.Split(out, "\r\n") { if len(line) > 75 { t.Errorf("a line is %d octets, over the 75 limit: %q", len(line), line) } } if !strings.Contains(out, "\r\n ") { t.Error("nothing was folded, so the fold was not exercised") } parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC") if err != nil { t.Fatalf("ParseICS: %v", err) } if len(parsed) != 1 || parsed[0].Title != long { t.Error("a folded title did not unfold back to itself") } } // An all-day event is a DATE, and must stay one. Exported as a datetime // at midnight it lands on the wrong day for everybody west of // Greenwich. func TestAllDayExportsAsADate(t *testing.T) { out := calendar.WriteICS("Team", []calendar.EventView{{ Event: calendar.Event{ ID: 1, AllDay: true, StartLocal: "2026-09-02", EndLocal: "2026-09-03", Kind: calendar.KindNote, Status: calendar.StatusConfirmed, }, Title: "Somebody's birthday", }}, false) if !strings.Contains(out, "DTSTART;VALUE=DATE:20260902") { t.Errorf("an all-day event did not export as a DATE:\n%s", out) } if strings.Contains(out, "DTSTART;VALUE=DATE:20260902T") { t.Error("an all-day DTSTART carries a time") } parsed, _, err := calendar.ParseICS(strings.NewReader(out), "UTC") if err != nil || len(parsed) != 1 { t.Fatalf("ParseICS: %v (%d events)", err, len(parsed)) } if !parsed[0].AllDay { t.Error("an all-day event round-tripped as timed") } if parsed[0].StartLocal != "2026-09-02" { t.Errorf("all-day start round-tripped as %q", parsed[0].StartLocal) } } // A file from another calendar must import as much as it honestly can, // and report the rest. This is a Google-shaped export with a foreign // rule, an attendee, and a VTIMEZONE block that must not leak its own // properties onto the event. func TestImportingAForeignFile(t *testing.T) { const file = "BEGIN:VCALENDAR\r\n" + "VERSION:2.0\r\nPRODID:-//Example//EN\r\n" + "BEGIN:VTIMEZONE\r\nTZID:Europe/Dublin\r\n" + "BEGIN:STANDARD\r\nDTSTART:19701025T020000\r\nTZOFFSETFROM:+0100\r\n" + "TZOFFSETTO:+0000\r\nEND:STANDARD\r\nEND:VTIMEZONE\r\n" + "BEGIN:VEVENT\r\nUID:x1@example.com\r\nSUMMARY:Retro\r\n" + "DTSTART;TZID=Europe/Dublin:20260902T140000\r\n" + "DTEND;TZID=Europe/Dublin:20260902T150000\r\n" + "RRULE:FREQ=MONTHLY;BYSETPOS=3;BYDAY=MO,TU,WE,TH,FR\r\n" + "ATTENDEE;CN=Someone:mailto:someone@example.com\r\n" + "END:VEVENT\r\n" + "BEGIN:VEVENT\r\nUID:x2@example.com\r\nSUMMARY:Floating thing\r\n" + "DTSTART:20260903T110000\r\nDTEND:20260903T120000\r\nEND:VEVENT\r\n" + "END:VCALENDAR\r\n" parsed, res, err := calendar.ParseICS(strings.NewReader(file), "UTC") if err != nil { t.Fatalf("ParseICS: %v", err) } if len(parsed) != 2 { t.Fatalf("imported %d events, want 2", len(parsed)) } // The VTIMEZONE's own DTSTART must not have landed on the event. if parsed[0].StartLocal != "2026-09-02T14:00" { t.Errorf("the first event's start is %q — a VTIMEZONE property leaked onto it", parsed[0].StartLocal) } if parsed[0].TZ != "Europe/Dublin" { t.Errorf("zone imported as %q", parsed[0].TZ) } // A rule outside the subset is KEPT, not dropped, and counted. if !strings.Contains(parsed[0].RRule, "BYSETPOS") { t.Error("a foreign rule was dropped rather than stored verbatim") } if res.ForeignRules != 1 { t.Errorf("ForeignRules = %d, want 1", res.ForeignRules) } // Guests are not modelled, and the count says so rather than the // lines vanishing silently. if res.Attendees != 1 { t.Errorf("Attendees = %d, want 1", res.Attendees) } // A zoneless event stays floating rather than being guessed into // the importer's zone. if parsed[1].TZ != "" { t.Errorf("a floating event was given zone %q", parsed[1].TZ) } if res.Floating != 1 { t.Errorf("Floating = %d, want 1", res.Floating) } } // A VEVENT with no DTEND takes the RFC's defaults rather than being // skipped. func TestImportAppliesDurationAndImpliedEnd(t *testing.T) { const file = "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\n" + "UID:d1\r\nSUMMARY:With a duration\r\n" + "DTSTART;TZID=UTC:20260902T090000\r\nDURATION:PT1H30M\r\n" + "END:VEVENT\r\nBEGIN:VEVENT\r\n" + "UID:d2\r\nSUMMARY:A whole day\r\n" + "DTSTART;VALUE=DATE:20260902\r\n" + "END:VEVENT\r\nEND:VCALENDAR\r\n" parsed, _, err := calendar.ParseICS(strings.NewReader(file), "UTC") if err != nil || len(parsed) != 2 { t.Fatalf("ParseICS: %v (%d events)", err, len(parsed)) } if parsed[0].EndLocal != "2026-09-02T10:30" { t.Errorf("DURATION:PT1H30M gave end %q, want 2026-09-02T10:30", parsed[0].EndLocal) } // An all-day event with no end is one day, and the end is // exclusive — so the following date. if parsed[1].EndLocal != "2026-09-03" { t.Errorf("an all-day event with no DTEND ends %q, want 2026-09-03", parsed[1].EndLocal) } }