Files
projax/caldav/caldav_test.go
mAi 83c965f111 feat(phase 2.b caldav): full read/write VTODO writeback from projax
caldav package:
- Todo carries URL, ETag, Raw so ListTodos rows can be PUT/DELETEd in place
- BuildVTodoICS for new VTODOs, ApplyVTodoEdit for in-place edits that
  preserve unknown properties (DESCRIPTION, CATEGORIES, X-*)
- PutTodo/DeleteTodo with If-Match optimistic concurrency
- ErrPreconditionFailed/ErrNotFound for 412/404
- RFC 5545 fold-at-75 + CRLF + text escape, hand-rolled UUID v4
- httptest round-trip (create -> list -> complete -> delete) plus 412 path

web:
- POST /i/{path}/caldav/todo/{complete,reopen,edit,delete,todo-create}
- Re-fetches the live ETag before each PUT/DELETE so ordinary use never
  trips 412; on actual 412 the section reloads with a banner
- Calendar URL must already be linked to the item (anti-forgery guard)
- tasks_section partial drives both the initial page render and HTMX
  swaps; detail.tmpl reduces to a one-liner template call

docs/design.md §5: rewrite for full read/write semantics + ETag concurrency.
2026-05-15 17:16:38 +02:00

441 lines
13 KiB
Go

package caldav
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"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[0].ETag != `"abc"` {
t.Errorf("todos[0].ETag = %q, want \"abc\"", todos[0].ETag)
}
if !strings.HasSuffix(todos[0].URL, "/dav/calendars/m/Work/todo-1.ics") {
t.Errorf("todos[0].URL = %q, want suffix /dav/calendars/m/Work/todo-1.ics", todos[0].URL)
}
if !strings.Contains(todos[0].Raw, "BEGIN:VTODO") {
t.Errorf("todos[0].Raw missing BEGIN:VTODO: %q", todos[0].Raw)
}
if todos[1].Status != "COMPLETED" {
t.Errorf("todos[1].Status = %q", todos[1].Status)
}
if todos[1].ETag != `"def"` {
t.Errorf("todos[1].ETag = %q", todos[1].ETag)
}
}
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)
}
}
// fakeCalendarStore is an in-memory calendar collection that implements just
// enough CalDAV for round-trip tests: REPORT for listing, PUT for create /
// edit (with If-Match), DELETE for removal (with If-Match). ETags are
// per-resource and bump on every write.
type fakeCalendarStore struct {
mu sync.Mutex
rows map[string]*fakeResource // path → resource
calBase string // server-relative path prefix, e.g. /dav/calendars/m/Work/
}
type fakeResource struct {
ics string
etag string
}
func (s *fakeCalendarStore) report() string {
s.mu.Lock()
defer s.mu.Unlock()
var b strings.Builder
b.WriteString(`<?xml version="1.0"?>` + "\n")
b.WriteString(`<d:multistatus xmlns:d="DAV:" xmlns:cal="urn:ietf:params:xml:ns:caldav">`)
for href, r := range s.rows {
b.WriteString(fmt.Sprintf(`<d:response><d:href>%s</d:href><d:propstat><d:prop><d:getetag>%s</d:getetag><cal:calendar-data>%s</cal:calendar-data></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`, href, r.etag, r.ics))
}
b.WriteString(`</d:multistatus>`)
return b.String()
}
func (s *fakeCalendarStore) handler(t *testing.T) http.HandlerFunc {
t.Helper()
var nextETag int
bump := func() string {
nextETag++
return fmt.Sprintf(`"etag-%d"`, nextETag)
}
return func(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case "REPORT":
w.Header().Set("Content-Type", "application/xml; charset=utf-8")
w.WriteHeader(207)
_, _ = io.WriteString(w, s.report())
case "PUT":
body, _ := io.ReadAll(r.Body)
s.mu.Lock()
defer s.mu.Unlock()
ifMatch := r.Header.Get("If-Match")
ifNone := r.Header.Get("If-None-Match")
existing, ok := s.rows[r.URL.Path]
if ifNone == "*" && ok {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
if ifMatch != "" && (!ok || existing.etag != ifMatch) {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
tag := bump()
s.rows[r.URL.Path] = &fakeResource{ics: string(body), etag: tag}
w.Header().Set("ETag", tag)
if !ok {
w.WriteHeader(http.StatusCreated)
} else {
w.WriteHeader(http.StatusNoContent)
}
case "DELETE":
s.mu.Lock()
defer s.mu.Unlock()
ifMatch := r.Header.Get("If-Match")
existing, ok := s.rows[r.URL.Path]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
if ifMatch != "" && existing.etag != ifMatch {
w.WriteHeader(http.StatusPreconditionFailed)
return
}
delete(s.rows, r.URL.Path)
w.WriteHeader(http.StatusNoContent)
default:
t.Errorf("unexpected method %q on %s", r.Method, r.URL.Path)
w.WriteHeader(http.StatusMethodNotAllowed)
}
}
}
func newRoundTripServer(t *testing.T) (*Client, *fakeCalendarStore) {
t.Helper()
store := &fakeCalendarStore{rows: map[string]*fakeResource{}, calBase: "/dav/calendars/m/RT/"}
srv := httptest.NewServer(store.handler(t))
t.Cleanup(srv.Close)
c := New(srv.URL+"/dav/calendars/m/", "u", "p")
return c, store
}
func TestRoundTripCreateListCompleteDelete(t *testing.T) {
c, _ := newRoundTripServer(t)
calURL := c.BaseURL + "RT/"
uid := NewUID()
url := TodoURLFor(calURL, uid)
// Create
summary := "Buy onions"
ics := BuildVTodoICS(uid, VTodoEdit{Summary: &summary})
etag, err := c.PutTodo(context.Background(), url, ics, "", "*")
if err != nil {
t.Fatalf("PUT create: %v", err)
}
if etag == "" {
t.Fatal("PUT create: empty new ETag")
}
// List → must contain the new UID
todos, err := c.ListTodos(context.Background(), calURL)
if err != nil {
t.Fatalf("ListTodos: %v", err)
}
if len(todos) != 1 {
t.Fatalf("expected 1 todo after create, got %d", len(todos))
}
got := todos[0]
if got.UID != uid {
t.Errorf("UID = %q, want %q", got.UID, uid)
}
if got.Summary != summary {
t.Errorf("Summary = %q, want %q", got.Summary, summary)
}
if got.Status != "NEEDS-ACTION" {
t.Errorf("Status = %q, want NEEDS-ACTION", got.Status)
}
if got.ETag == "" {
t.Error("listed Todo missing ETag")
}
// Complete in-place — preserve unknown fields by edit, not rebuild.
completed := "COMPLETED"
updated := ApplyVTodoEdit(got.Raw, VTodoEdit{Status: &completed})
if !strings.Contains(updated, "STATUS:COMPLETED") {
t.Fatalf("edited ICS missing STATUS:COMPLETED:\n%s", updated)
}
if !strings.Contains(updated, "COMPLETED:") {
t.Fatalf("edited ICS missing COMPLETED line:\n%s", updated)
}
newETag, err := c.PutTodo(context.Background(), got.URL, updated, got.ETag, "")
if err != nil {
t.Fatalf("PUT complete: %v", err)
}
if newETag == got.ETag {
t.Errorf("ETag did not bump on complete (still %q)", newETag)
}
// Re-list — status should reflect.
todos, err = c.ListTodos(context.Background(), calURL)
if err != nil {
t.Fatalf("ListTodos post-complete: %v", err)
}
if len(todos) != 1 || todos[0].Status != "COMPLETED" {
t.Fatalf("expected single COMPLETED todo, got %+v", todos)
}
// Stale ETag on edit must yield ErrPreconditionFailed.
if _, err := c.PutTodo(context.Background(), got.URL, updated, "stale-etag", ""); err != ErrPreconditionFailed {
t.Errorf("expected ErrPreconditionFailed on stale ETag, got %v", err)
}
// Delete with current ETag.
if err := c.DeleteTodo(context.Background(), todos[0].URL, todos[0].ETag); err != nil {
t.Fatalf("DELETE: %v", err)
}
todos, err = c.ListTodos(context.Background(), calURL)
if err != nil {
t.Fatalf("ListTodos post-delete: %v", err)
}
if len(todos) != 0 {
t.Fatalf("expected empty list after delete, got %d", len(todos))
}
}
func TestApplyVTodoEditPreservesUnknown(t *testing.T) {
raw := "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nBEGIN:VTODO\r\nUID:keep-me\r\nSUMMARY:Old\r\nSTATUS:NEEDS-ACTION\r\nDESCRIPTION:Long context that must not vanish\r\nCATEGORIES:home,errands\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
newSummary := "New"
got := ApplyVTodoEdit(raw, VTodoEdit{Summary: &newSummary})
if !strings.Contains(got, "SUMMARY:New") {
t.Errorf("expected SUMMARY:New, got:\n%s", got)
}
if strings.Contains(got, "SUMMARY:Old") {
t.Errorf("old SUMMARY still present:\n%s", got)
}
if !strings.Contains(got, "DESCRIPTION:Long context") {
t.Errorf("DESCRIPTION dropped during edit:\n%s", got)
}
if !strings.Contains(got, "CATEGORIES:home,errands") {
t.Errorf("CATEGORIES dropped during edit:\n%s", got)
}
if !strings.Contains(got, "UID:keep-me") {
t.Errorf("UID dropped during edit:\n%s", got)
}
}
func TestBuildVTodoICSFolding(t *testing.T) {
long := strings.Repeat("ABCDEFGH", 20) // 160 chars — must fold across multiple lines.
ics := BuildVTodoICS("uid-fold", VTodoEdit{Summary: &long})
// Every physical line must be ≤ 75 octets after fold.
for _, ln := range strings.Split(ics, "\r\n") {
if len(ln) > 75 {
t.Errorf("line exceeds 75 octets after fold (%d): %q", len(ln), ln)
}
}
// Unfolding should recover the original SUMMARY value.
parsed := parseVTodos(ics)
if len(parsed) != 1 || parsed[0].Summary != long {
t.Fatalf("round-trip SUMMARY mismatch, got %+v", parsed)
}
}
func TestEscapeText(t *testing.T) {
in := "Pick up, eggs; cheese\\bread\nnext line"
got := escapeText(in)
want := `Pick up\, eggs\; cheese\\bread\nnext line`
if got != want {
t.Errorf("escapeText(%q) = %q, want %q", in, got, want)
}
if back := unescapeText(got); back != in {
t.Errorf("unescape round-trip: %q != %q", back, in)
}
}
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>`