Files
projax/caldav/caldav_test.go
mAi 96b61f7ed4 feat(phase 2 caldav): list + link + create CalDAV calendars
m's CalDAV server (dav.msbls.de, SabreDAV) now feeds projax via a thin
read-only-plus-create-on-demand integration. No background sync; tasks
fetched live on detail-page render.

New caldav/ package
- ListCalendars (PROPFIND Depth: 1, filters non-calendar collections)
- ListTodos (REPORT calendar-query for VTODO; hand-rolled iCalendar
  parser for UID/SUMMARY/STATUS/DUE/PRIORITY/LAST-MODIFIED — RFC 5545
  line-folding aware)
- CreateCalendar (MKCALENDAR, 405 → ErrCalendarExists for the "link
  instead" branch)
- httptest-stubbed tests cover all four paths.

Store
- ItemLink shape + LinksByType / LinksByRefType / AddLink / DeleteLink.
  AddLink upserts on (item_id, ref_type, ref_id, rel) so re-linking the
  same calendar is idempotent.

Web
- GET /admin/caldav — discovery + auto-suggested matches + manual
  linker. Suggestion = lowercased displayname == projax slug or title.
- POST /admin/caldav/link — insert item_links row.
- POST /admin/caldav/unlink — delete by link id.
- POST /i/{path}/caldav/create — MKCALENDAR at <base>/<slug>/, then
  AddLink. On 405 (already exists), fall back to link-only.
- Detail page Tasks section: per-calendar block with open VTODOs +
  collapsed completed (30d window). Errors per calendar logged and
  skipped, so one bad calendar does not blank the page.
- nav adds /admin/caldav link.

main.go
- DAV_URL + DAV_USER + DAV_PASSWORD optional. Missing DAV_URL → CalDAV
  off (admin page renders "not configured" notice). DAV_URL set but
  user/pass missing → fail fast at boot.

docs/design.md gains §5 documenting the integration shape.
deploy/dokploy.yaml lists the two new secrets + the env var.

Phase 2.b (writeback / two-way / background sync) is parked.
2026-05-15 16:57:43 +02:00

199 lines
5.5 KiB
Go

package caldav
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// newFakeServer stubs the slice of CalDAV that the client exercises. Each
// handler asserts the method and returns a canned XML body so the parser can
// be exercised end-to-end without a real DAV server.
func newFakeServer(t *testing.T) (*Client, *httptest.Server) {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/dav/calendars/m/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "PROPFIND" {
t.Errorf("unexpected method %q on collection", r.Method)
}
w.WriteHeader(207)
_, _ = io.WriteString(w, propfindBody)
})
mux.HandleFunc("/dav/calendars/m/Work/", func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "REPORT":
w.WriteHeader(207)
_, _ = io.WriteString(w, reportBody)
case "MKCALENDAR":
w.WriteHeader(http.StatusMethodNotAllowed)
default:
t.Errorf("unexpected method %q on Work/", r.Method)
}
})
mux.HandleFunc("/dav/calendars/m/new-list/", func(w http.ResponseWriter, r *http.Request) {
if r.Method != "MKCALENDAR" {
t.Errorf("unexpected method %q on new-list/", r.Method)
}
body, _ := io.ReadAll(r.Body)
if !strings.Contains(string(body), "<d:displayname>Paliad</d:displayname>") {
t.Errorf("MKCALENDAR body missing display name: %s", body)
}
w.WriteHeader(http.StatusCreated)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return New(srv.URL+"/dav/calendars/m/", "u", "p"), srv
}
func TestListCalendars(t *testing.T) {
c, _ := newFakeServer(t)
cals, err := c.ListCalendars(context.Background())
if err != nil {
t.Fatalf("ListCalendars: %v", err)
}
want := map[string]bool{"mCalendar": true, "Birthdays": true, "Work": true}
got := map[string]bool{}
for _, cal := range cals {
got[cal.DisplayName] = true
}
for k := range want {
if !got[k] {
t.Errorf("expected calendar %q in result, got %v", k, got)
}
}
}
func TestListTodos(t *testing.T) {
c, _ := newFakeServer(t)
todos, err := c.ListTodos(context.Background(), c.BaseURL+"Work/")
if err != nil {
t.Fatalf("ListTodos: %v", err)
}
if len(todos) != 2 {
t.Fatalf("expected 2 todos, got %d (%v)", len(todos), todos)
}
if todos[0].UID != "todo-1@example" {
t.Errorf("todos[0].UID = %q", todos[0].UID)
}
if todos[0].Summary != "Pick up bread" {
t.Errorf("todos[0].Summary = %q", todos[0].Summary)
}
if todos[0].Status != "NEEDS-ACTION" {
t.Errorf("todos[0].Status = %q", todos[0].Status)
}
if todos[1].Status != "COMPLETED" {
t.Errorf("todos[1].Status = %q", todos[1].Status)
}
}
func TestCreateCalendar(t *testing.T) {
c, _ := newFakeServer(t)
if err := c.CreateCalendar(context.Background(), c.BaseURL+"new-list/", "Paliad", "#bff355"); err != nil {
t.Fatalf("CreateCalendar: %v", err)
}
}
func TestCreateCalendarExists(t *testing.T) {
c, _ := newFakeServer(t)
err := c.CreateCalendar(context.Background(), c.BaseURL+"Work/", "Work", "")
if err != ErrCalendarExists {
t.Fatalf("expected ErrCalendarExists, got %v", err)
}
}
const propfindBody = `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav" xmlns:cs="http://calendarserver.org/ns/">
<d:response>
<d:href>/dav/calendars/m/</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/m/default/</d:href>
<d:propstat>
<d:prop>
<d:displayname>mCalendar</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/m/birthday-calendar/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Birthdays</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/m/Work/</d:href>
<d:propstat>
<d:prop>
<d:displayname>Work</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/m/inbox/</d:href>
<d:propstat>
<d:prop>
<d:displayname>inbox</d:displayname>
<d:resourcetype><d:collection/></d:resourcetype>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`
const reportBody = `<?xml version="1.0"?>
<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">
<d:response>
<d:href>/dav/calendars/m/Work/todo-1.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>"abc"</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
BEGIN:VTODO
UID:todo-1@example
SUMMARY:Pick up bread
STATUS:NEEDS-ACTION
PRIORITY:5
DUE:20260601T120000Z
END:VTODO
END:VCALENDAR</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
<d:response>
<d:href>/dav/calendars/m/Work/todo-2.ics</d:href>
<d:propstat>
<d:prop>
<d:getetag>"def"</d:getetag>
<cal:calendar-data>BEGIN:VCALENDAR
BEGIN:VTODO
UID:todo-2@example
SUMMARY:Filed paperwork
STATUS:COMPLETED
END:VTODO
END:VCALENDAR</cal:calendar-data>
</d:prop>
<d:status>HTTP/1.1 200 OK</d:status>
</d:propstat>
</d:response>
</d:multistatus>`