Add Calendar integration (read-only)
This commit is contained in:
@@ -0,0 +1,283 @@
|
||||
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, `<?xml version="1.0"?>
|
||||
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
|
||||
<d:response><d:href>/dav/calendars/me/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/gewicht/</d:href><d:propstat><d:prop><d:displayname>Gewicht</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/broken/</d:href><d:propstat><d:prop><d:displayname>Broken/Name</d:displayname><d:resourcetype><d:collection/><cal:calendar/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
<d:response><d:href>/dav/calendars/me/inbox/</d:href><d:propstat><d:prop><d:resourcetype><d:collection/><cal:schedule-inbox/></d:resourcetype></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>
|
||||
</d:multistatus>`)
|
||||
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, "<html>login</html>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user