package main import ( "io" "net/http" "net/http/httptest" "os" "path/filepath" "strings" "testing" "time" ) // writeCal writes a VCALENDAR with the given VEVENT bodies to dir/name.ics. func writeCal(t *testing.T, dir, name string, events ...string) { t.Helper() var b strings.Builder b.WriteString("BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:test\r\n") for _, ev := range events { b.WriteString("BEGIN:VEVENT\r\n") for _, line := range strings.Split(strings.TrimSpace(ev), "\n") { b.WriteString(strings.TrimSpace(line) + "\r\n") } b.WriteString("END:VEVENT\r\n") } b.WriteString("END:VCALENDAR\r\n") if err := os.WriteFile(filepath.Join(dir, name+".ics"), []byte(b.String()), 0644); err != nil { t.Fatal(err) } } func withBerlin(t *testing.T) { t.Helper() loc, err := time.LoadLocation("Europe/Berlin") if err != nil { t.Fatal(err) } prev := time.Local time.Local = loc t.Cleanup(func() { time.Local = prev }) } func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 0, 0, 0, 0, time.Local) } func summaries(evs []calEvent) []string { var out []string for _, ev := range evs { out = append(out, ev.Start.Format("2006-01-02 15:04")+" "+ev.Summary) } return out } func assertStrings(t *testing.T, got, want []string) { t.Helper() if strings.Join(got, "\n") != strings.Join(want, "\n") { t.Fatalf("got:\n %s\nwant:\n %s", strings.Join(got, "\n "), strings.Join(want, "\n ")) } } func TestCalendarSingleAndAllDay(t *testing.T) { withBerlin(t) dir := t.TempDir() writeCal(t, dir, "P", `UID:a SUMMARY:Zahnarzt DTSTART;TZID=Europe/Berlin:20261002T090000 DTEND;TZID=Europe/Berlin:20261002T100000`, `UID:b SUMMARY:Urlaub DTSTART;VALUE=DATE:20261001 DTEND;VALUE=DATE:20261004`, `UID:c SUMMARY:UTC call DTSTART:20261002T160000Z DURATION:PT1H`, ) got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3))) assertStrings(t, got, []string{ "2026-10-01 00:00 Urlaub", "2026-10-02 09:00 Zahnarzt", "2026-10-02 18:00 UTC call", }) // DTEND of all-day events is exclusive. if got := calendarOccurrences(dir, []string{"P"}, day(2026, 10, 4), day(2026, 10, 5)); len(got) != 0 { t.Fatalf("all-day event leaked past DTEND: %v", summaries(got)) } } func TestCalendarRecurrence(t *testing.T) { withBerlin(t) dir := t.TempDir() writeCal(t, dir, "M", `UID:backup SUMMARY:Backup DTSTART;TZID=Europe/Berlin:20260907T030000 DTEND;TZID=Europe/Berlin:20260907T040000 RRULE:FREQ=WEEKLY;BYDAY=MO EXDATE;TZID=Europe/Berlin:20260914T030000,20260921T030000`, `UID:backup RECURRENCE-ID;TZID=Europe/Berlin:20260928T030000 SUMMARY:Backup (moved) DTSTART;TZID=Europe/Berlin:20260929T050000 DTEND;TZID=Europe/Berlin:20260929T060000`, `UID:backup RECURRENCE-ID;TZID=Europe/Berlin:20261005T030000 DTSTART;TZID=Europe/Berlin:20261005T030000 STATUS:CANCELLED`, `UID:sheets SUMMARY:Bettwäsche DTSTART;VALUE=DATE:20260905 RRULE:FREQ=WEEKLY;INTERVAL=2`, ) got := summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 9, 1), day(2026, 10, 20))) assertStrings(t, got, []string{ "2026-09-05 00:00 Bettwäsche", "2026-09-07 03:00 Backup", "2026-09-19 00:00 Bettwäsche", "2026-09-29 05:00 Backup (moved)", "2026-10-03 00:00 Bettwäsche", "2026-10-12 03:00 Backup", "2026-10-17 00:00 Bettwäsche", "2026-10-19 03:00 Backup", }) // Wall-clock time survives the DST change on 2026-10-25. got = summaries(calendarOccurrences(dir, []string{"M"}, day(2026, 10, 26), day(2026, 10, 27))) assertStrings(t, got, []string{"2026-10-26 03:00 Backup"}) evs := calendarOccurrences(dir, []string{"M"}, day(2026, 9, 5), day(2026, 9, 8)) if evs[0].Series == nil || evs[0].Series.Rule != "every 2 weeks" { t.Fatalf("sheets rule = %+v", evs[0].Series) } if evs[1].Series == nil || evs[1].Series.Rule != "weekly (Mo)" { t.Fatalf("backup rule = %+v", evs[1].Series) } } func TestCalendarUnknownTZIDFallsBack(t *testing.T) { withBerlin(t) dir := t.TempDir() writeCal(t, dir, "P", `UID:x SUMMARY:Windows zone DTSTART;TZID=W. Europe Standard Time:20261002T090000 DTEND;TZID=W. Europe Standard Time:20261002T100000`, `UID:y SUMMARY:Prefixed zone DTSTART;TZID=/freeassociation.sourceforge.net/Tzfile/Europe/Berlin:20261002T110000`, ) got := summaries(calendarOccurrences(dir, []string{"P"}, day(2026, 10, 2), day(2026, 10, 3))) assertStrings(t, got, []string{ "2026-10-02 09:00 Windows zone", "2026-10-02 11:00 Prefixed zone", }) } func TestCalendarDayLines(t *testing.T) { withBerlin(t) dir := t.TempDir() prev := calendarsDir calendarsDir = dir t.Cleanup(func() { calendarsDir = prev }) if err := os.WriteFile(filepath.Join(dir, ".page-settings"), []byte("type = calendars\ndiary = Personal, Feiertage\nweight = Gewicht\nmaintenance = Maintainance\n"), 0644); err != nil { t.Fatal(err) } writeCal(t, dir, "Personal", `UID:1 SUMMARY:Kino DTSTART;TZID=Europe/Berlin:20261003T193000`, `UID:2 SUMMARY:Brunch\, Anna DTSTART;TZID=Europe/Berlin:20261003T100000`, ) writeCal(t, dir, "Feiertage", `UID:3 SUMMARY:Tag der Deutschen Einheit DTSTART;VALUE=DATE:20261003`, ) writeCal(t, dir, "Gewicht", `UID:4 SUMMARY:95.9 KG DTSTART;VALUE=DATE:20261003 DTSTAMP:20261003T060000Z`, `UID:5 SUMMARY:95,6kg DTSTART;VALUE=DATE:20261003 DTSTAMP:20261003T070000Z`, `UID:6 SUMMARY:not a weight DTSTART;VALUE=DATE:20261004`, ) writeCal(t, dir, "Maintainance", `UID:7 SUMMARY:Backup DTSTART;VALUE=DATE:20261003`, ) lines := calendarDayLines(day(2026, 10, 1), day(2026, 10, 10)) want := calDayLines{ Events: "Tag der Deutschen Einheit, 10:00 Brunch, Anna, 19:30 Kino", Weight: "95,6 kg", } if lines["2026-10-03"] != want { t.Fatalf("got %+v, want %+v", lines["2026-10-03"], want) } if _, ok := lines["2026-10-04"]; ok { t.Fatalf("unparsable weight produced a line: %+v", lines["2026-10-04"]) } } func TestCalendarSync(t *testing.T) { dir := t.TempDir() prev := calendarsDir calendarsDir = dir t.Cleanup(func() { calendarsDir = prev }) ics := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VEVENT\r\nUID:1\r\nSUMMARY:95,6kg\r\nDTSTART;VALUE=DATE:20261003\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if u, p, _ := r.BasicAuth(); u != "me" || p != "secret" { w.WriteHeader(http.StatusUnauthorized) return } switch { case r.Method == "PROPFIND" && r.URL.Path == "/dav/calendars/me/": w.WriteHeader(http.StatusMultiStatus) io.WriteString(w, ` /dav/calendars/me/HTTP/1.1 200 OK /dav/calendars/me/gewicht/GewichtHTTP/1.1 200 OK /dav/calendars/me/broken/Broken/NameHTTP/1.1 200 OK /dav/calendars/me/inbox/HTTP/1.1 200 OK `) case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/gewicht/" && r.URL.RawQuery == "export": io.WriteString(w, ics) case r.Method == http.MethodGet && r.URL.Path == "/dav/calendars/me/broken/": io.WriteString(w, "login") default: http.NotFound(w, r) } })) defer srv.Close() // An existing file must survive a broken download. if err := os.WriteFile(filepath.Join(dir, "Broken_Name.ics"), []byte("old"), 0644); err != nil { t.Fatal(err) } results, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me", CaldavUser: "me", CaldavPass: "secret"}) if err != nil { t.Fatal(err) } if len(results) != 2 || results["Gewicht"].Error != "" || results["Broken_Name"].Error == "" { t.Fatalf("results = %+v", results) } if got, _ := os.ReadFile(filepath.Join(dir, "Gewicht.ics")); string(got) != ics { t.Fatalf("Gewicht.ics = %q", got) } if got, _ := os.ReadFile(filepath.Join(dir, "Broken_Name.ics")); string(got) != "old" { t.Fatalf("broken download replaced the file: %q", got) } if _, err := syncCalendars(calendarSettings{CaldavURL: srv.URL + "/dav/calendars/me/", CaldavUser: "me", CaldavPass: "wrong"}); err == nil || !strings.Contains(err.Error(), "authentication") { t.Fatalf("wrong password: err = %v", err) } } func TestHasDotSegment(t *testing.T) { for p, want := range map[string]bool{ "/Calendars/.page-settings": true, "/.auth-key": true, "/a/.hidden/b.txt": true, "/Calendars/Personal.ics": false, "/a/b.c/": false, "/a/../.auth-key": true, "/": false, } { if got := hasDotSegment(p); got != want { t.Errorf("hasDotSegment(%q) = %v, want %v", p, got, want) } } }