7dba1b7935
FactSpan built both instants by adding a duration to local midnight, so on the two DST changeover days every span was an hour off. A day is 23 or 25 hours wide there, and the busy gate then read a 14:00 meeting as 13:00 or 15:00. Both readings are time.Date now, and the midnight crossing is AddDate rather than a 24-hour add. The iCal parse did not unfold content lines. A server folds a property at 75 octets and a Russian summary is two bytes a letter, so the tail of an ordinary weekly standup was read as an unknown property and dropped, and the event was filed under a truncated name. RFC 5545 TEXT escapes are also reversed now, which RenderICal has always written and the parse never undid. Two regression tests: a folded and escaped summary, and a span across the start of DST in Europe/Berlin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
108 lines
3.9 KiB
Go
108 lines
3.9 KiB
Go
package calendar
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestRenderICalRoundTrips(t *testing.T) {
|
|
e := ReminderEvent(7, time.Date(2026, 8, 1, 18, 30, 0, 0, time.UTC), "позвонить маме", 0)
|
|
if e.UID != "maven-reminder-7" {
|
|
t.Errorf("UID = %q", e.UID)
|
|
}
|
|
if got := e.End.Sub(e.Start); got != DefaultReminderDuration {
|
|
t.Errorf("duration = %v, want %v", got, DefaultReminderDuration)
|
|
}
|
|
if got, want := ReminderPath(7), "maven-reminder-7.ics"; got != want {
|
|
t.Errorf("ReminderPath = %q, want %q", got, want)
|
|
}
|
|
|
|
body := RenderICal([]Event{e})
|
|
if !strings.HasPrefix(body, "BEGIN:VCALENDAR\r\n") || !strings.HasSuffix(body, "END:VCALENDAR\r\n") {
|
|
t.Fatalf("not a VCALENDAR body:\n%s", body)
|
|
}
|
|
|
|
back := ParseICal([]byte(body), e.Start.Add(-time.Hour), e.Start.Add(time.Hour))
|
|
if len(back) != 1 {
|
|
t.Fatalf("got %d events back, want 1:\n%s", len(back), body)
|
|
}
|
|
if back[0].UID != e.UID || back[0].Summary != e.Summary {
|
|
t.Errorf("round trip lost identity: %+v", back[0])
|
|
}
|
|
if !back[0].Start.Equal(e.Start) || !back[0].End.Equal(e.End) {
|
|
t.Errorf("round trip lost times: %+v", back[0])
|
|
}
|
|
}
|
|
|
|
func TestRenderICalIsDeterministic(t *testing.T) {
|
|
e := ReminderEvent(1, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), "выпить воды", 0)
|
|
if RenderICal([]Event{e}) != RenderICal([]Event{e}) {
|
|
t.Error("the same reminder must render byte-identically, or every poll re-PUTs it")
|
|
}
|
|
}
|
|
|
|
// A reminder payload is owner-supplied text. It must not be able to close the
|
|
// VEVENT and inject properties of its own.
|
|
func TestRenderICalEscapesInjection(t *testing.T) {
|
|
e := ReminderEvent(2, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC),
|
|
"обед\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nSUMMARY:injected", 0)
|
|
body := RenderICal([]Event{e})
|
|
// Count line-initial occurrences: the escaped text still contains the
|
|
// characters "BEGIN:VEVENT", it just can no longer start a line.
|
|
if n := strings.Count(body, "\r\nBEGIN:VEVENT\r\n"); n != 1 {
|
|
t.Fatalf("payload injected a second VEVENT (%d):\n%s", n, body)
|
|
}
|
|
if n := strings.Count(body, "\r\nEND:VEVENT\r\n"); n != 1 {
|
|
t.Fatalf("payload closed the VEVENT early (%d):\n%s", n, body)
|
|
}
|
|
if !strings.Contains(body, `SUMMARY:обед\nEND:VEVENT`) {
|
|
t.Errorf("newlines should be escaped, not dropped:\n%s", body)
|
|
}
|
|
}
|
|
|
|
// A folded SUMMARY is one property, not a property plus a dropped tail. Servers
|
|
// fold at 75 octets and a Russian summary is two bytes a letter.
|
|
func TestParseICalUnfoldsAndUnescapes(t *testing.T) {
|
|
body := []byte("BEGIN:VEVENT\r\n" +
|
|
"UID:u1\r\n" +
|
|
"DTSTART:20260703T130000Z\r\n" +
|
|
"DTEND:20260703T140000Z\r\n" +
|
|
"SUMMARY:Еженедельная планёрка\\, потом\r\n созвон\r\n" +
|
|
"END:VEVENT\r\n")
|
|
from := time.Date(2026, 7, 3, 0, 0, 0, 0, time.UTC)
|
|
events := ParseICal(body, from, from.AddDate(0, 0, 1))
|
|
if len(events) != 1 {
|
|
t.Fatalf("got %d events, want 1", len(events))
|
|
}
|
|
if want := "Еженедельная планёрка, потом созвон"; events[0].Summary != want {
|
|
t.Errorf("Summary = %q, want %q", events[0].Summary, want)
|
|
}
|
|
}
|
|
|
|
// A day is 23 hours wide where DST starts, so a wall clock reading has to be
|
|
// built with time.Date and never as midnight plus a duration.
|
|
func TestFactSpanAcrossDSTStart(t *testing.T) {
|
|
loc, err := time.LoadLocation("Europe/Berlin")
|
|
if err != nil {
|
|
t.Skipf("no tzdata for Europe/Berlin: %v", err)
|
|
}
|
|
start, end, ok := FactSpan("calendar_event_20260329_Planerka", "Planerka @ 14:00-15:00", loc)
|
|
if !ok {
|
|
t.Fatal("FactSpan reported not ok")
|
|
}
|
|
if start.Hour() != 14 || start.Minute() != 0 {
|
|
t.Errorf("start = %s, want a 14:00 wall clock", start)
|
|
}
|
|
if end.Hour() != 15 {
|
|
t.Errorf("end = %s, want a 15:00 wall clock", end)
|
|
}
|
|
}
|
|
|
|
func TestReminderEventEmptyPayload(t *testing.T) {
|
|
e := ReminderEvent(3, time.Date(2026, 8, 1, 9, 0, 0, 0, time.UTC), " ", 0)
|
|
if e.Summary != "напоминание" {
|
|
t.Errorf("Summary = %q, want the neutral RU fallback", e.Summary)
|
|
}
|
|
}
|