From e2478e2fd68d66b5628f34c31b818c90ddf7fdf5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:36:20 +0100 Subject: [PATCH] test(caldav): add caldavtests package with infrastructure, helpers, and mage target - Package skeleton with TestMain, setupTestEnv, and fixture users - HTTP request helpers (PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS) - XML/iCal response parsers and assertion utilities - VTodoBuilder for constructing test VTODO payloads - Common PROPFIND/REPORT XML body constants - Smoke test validating the infrastructure works end-to-end - mage test:caldav command and CI matrix entry --- .github/workflows/test.yml | 1 + magefile.go | 9 +- pkg/caldavtests/integrations.go | 154 ++++++++++++++++++++++ pkg/caldavtests/main_test.go | 32 +++++ pkg/caldavtests/propfind_bodies.go | 107 +++++++++++++++ pkg/caldavtests/smoke_test.go | 36 ++++++ pkg/caldavtests/vtodo_builder.go | 200 +++++++++++++++++++++++++++++ pkg/caldavtests/xml_helpers.go | 160 +++++++++++++++++++++++ 8 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 pkg/caldavtests/integrations.go create mode 100644 pkg/caldavtests/main_test.go create mode 100644 pkg/caldavtests/propfind_bodies.go create mode 100644 pkg/caldavtests/smoke_test.go create mode 100644 pkg/caldavtests/vtodo_builder.go create mode 100644 pkg/caldavtests/xml_helpers.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1318a49b6..c9520e2c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,7 @@ jobs: test: - feature - web + - caldav - e2e-api exclude: - db: sqlite diff --git a/magefile.go b/magefile.go index 9f4c32107..ebf0d7b3c 100644 --- a/magefile.go +++ b/magefile.go @@ -447,7 +447,14 @@ func (Test) Filter(ctx context.Context, filter string) error { func (Test) All() { mg.Deps(initVars) - mg.Deps(Test.Feature, Test.Web, Test.E2EApi) + mg.Deps(Test.Feature, Test.Web, Test.Caldav, Test.E2EApi) +} + +// Caldav runs the CalDAV protocol compliance tests in pkg/caldavtests. +// These tests exercise the full HTTP router with WebDAV/CalDAV requests. +func (Test) Caldav(ctx context.Context) error { + mg.Deps(initVars) + return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/caldavtests") } // E2EApi runs the end-to-end API tests in pkg/e2etests. diff --git a/pkg/caldavtests/integrations.go b/pkg/caldavtests/integrations.go new file mode 100644 index 000000000..dbf5bd445 --- /dev/null +++ b/pkg/caldavtests/integrations.go @@ -0,0 +1,154 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +// These are the test users, the same way they are in the test database +var ( + testuser1 = user.User{ + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user1@example.com", + Issuer: "local", + } + testuser15 = user.User{ + ID: 15, + Username: "user15", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user15@example.com", + Issuer: "local", + } +) + +// fixturePassword is the plaintext password for all test fixture users +const fixturePassword = "12345678" + +func setupTestEnv(t *testing.T) *echo.Echo { + t.Helper() + + config.InitDefaultConfig() + config.ServicePublicURL.Set("https://localhost") + + log.InitLogger() + files.InitTests() + user.InitTests() + models.SetupTests() + events.Fake() + keyvalue.InitStorage() + + err := db.LoadFixtures() + require.NoError(t, err) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// basicAuthHeader returns the Authorization header value for HTTP Basic Auth. +func basicAuthHeader(username, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +// caldavRequest sends an HTTP request through the full Echo router and returns the response. +func caldavRequest(t *testing.T, e *echo.Echo, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + + // Default to testuser15 basic auth (the caldav test user) unless overridden + if _, hasAuth := headers["Authorization"]; !hasAuth { + req.Header.Set("Authorization", basicAuthHeader(testuser15.Username, fixturePassword)) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// caldavPROPFIND sends a PROPFIND request. +func caldavPROPFIND(t *testing.T, e *echo.Echo, path, depth, body string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, "PROPFIND", path, body, map[string]string{ + "Depth": depth, + }) +} + +// caldavREPORT sends a REPORT request. +func caldavREPORT(t *testing.T, e *echo.Echo, path, body string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, "REPORT", path, body, nil) +} + +// caldavGET sends a GET request. +func caldavGET(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodGet, path, "", nil) +} + +// caldavPUT sends a PUT request with iCalendar content. +func caldavPUT(t *testing.T, e *echo.Echo, path, vcalendar string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodPut, path, vcalendar, map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + }) +} + +// caldavDELETE sends a DELETE request. +func caldavDELETE(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodDelete, path, "", nil) +} + +// caldavOPTIONS sends an OPTIONS request. +func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodOptions, path, "", nil) +} + +// caldavRequestAsUser sends a request authenticated as a specific user. +func caldavRequestAsUser(t *testing.T, e *echo.Echo, method, path, body string, u *user.User, password string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, method, path, body, map[string]string{ + "Authorization": basicAuthHeader(u.Username, password), + }) +} diff --git a/pkg/caldavtests/main_test.go b/pkg/caldavtests/main_test.go new file mode 100644 index 000000000..db915f8b5 --- /dev/null +++ b/pkg/caldavtests/main_test.go @@ -0,0 +1,32 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "flag" + "os" + "testing" +) + +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + println("-short requested, skipping long-running caldav tests") + return + } + os.Exit(m.Run()) +} diff --git a/pkg/caldavtests/propfind_bodies.go b/pkg/caldavtests/propfind_bodies.go new file mode 100644 index 000000000..a0f5febb1 --- /dev/null +++ b/pkg/caldavtests/propfind_bodies.go @@ -0,0 +1,107 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +// PROPFIND request bodies used by CalDAV clients. + +// PropfindCurrentUserPrincipal requests the current-user-principal property. +// RFC 5397 §3 +const PropfindCurrentUserPrincipal = ` + + + + +` + +// PropfindCalendarHomeSet requests the calendar-home-set property. +// RFC 4791 §6.2.1 +const PropfindCalendarHomeSet = ` + + + + +` + +// PropfindCalendarCollectionProperties requests common calendar collection properties. +// RFC 4791 §5.2 +const PropfindCalendarCollectionProperties = ` + + + + + + + + + +` + +// PropfindResourceProperties requests properties of a calendar resource (task). +const PropfindResourceProperties = ` + + + + + +` + +// PropfindAllProps requests all properties (allprop). +// RFC 4918 §9.1 +const PropfindAllProps = ` + + +` + +// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property. +// RFC 3744 §5.4 +const PropfindCurrentUserPrivilegeSet = ` + + + + +` + +// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs. +// RFC 4791 §7.8 +const ReportCalendarQuery = ` + + + + + + + + + + +` + +// ReportCalendarMultiget builds a calendar-multiget REPORT for specific hrefs. +// RFC 4791 §7.9 +func ReportCalendarMultiget(hrefs ...string) string { + var hrefXML string + for _, href := range hrefs { + hrefXML += " " + href + "\n" + } + return ` + + + + + +` + hrefXML + `` +} diff --git a/pkg/caldavtests/smoke_test.go b/pkg/caldavtests/smoke_test.go new file mode 100644 index 000000000..2a1081c41 --- /dev/null +++ b/pkg/caldavtests/smoke_test.go @@ -0,0 +1,36 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSmoke(t *testing.T) { + t.Run("GET /dav/projects/36 returns VCALENDAR", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36") + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "BEGIN:VTODO") + }) +} diff --git a/pkg/caldavtests/vtodo_builder.go b/pkg/caldavtests/vtodo_builder.go new file mode 100644 index 000000000..ffd57b389 --- /dev/null +++ b/pkg/caldavtests/vtodo_builder.go @@ -0,0 +1,200 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "fmt" + "strings" + "time" +) + +// VTodoBuilder constructs VCALENDAR/VTODO strings for test requests. +type VTodoBuilder struct { + uid string + summary string + description string + priority int + due time.Time + dtstart time.Time + completed time.Time + status string + categories []string + relatedTo []relatedToEntry + alarms []alarmEntry + rrule string + color string + percentComplete int + sequence int + duration string + dtstamp time.Time + created time.Time + lastMod time.Time + extraProps []string +} + +type relatedToEntry struct { + reltype string // "PARENT", "CHILD", or "" + uid string +} + +type alarmEntry struct { + trigger string + action string + description string +} + +// NewVTodo starts building a VTODO with required fields. +func NewVTodo(uid, summary string) *VTodoBuilder { + return &VTodoBuilder{ + uid: uid, + summary: summary, + dtstamp: time.Now().UTC(), + created: time.Now().UTC(), + lastMod: time.Now().UTC(), + } +} + +func (b *VTodoBuilder) Description(d string) *VTodoBuilder { b.description = d; return b } +func (b *VTodoBuilder) Priority(p int) *VTodoBuilder { b.priority = p; return b } +func (b *VTodoBuilder) Due(t time.Time) *VTodoBuilder { b.due = t; return b } +func (b *VTodoBuilder) DtStart(t time.Time) *VTodoBuilder { b.dtstart = t; return b } +func (b *VTodoBuilder) Completed(t time.Time) *VTodoBuilder { b.completed = t; return b } +func (b *VTodoBuilder) Status(s string) *VTodoBuilder { b.status = s; return b } +func (b *VTodoBuilder) Categories(c ...string) *VTodoBuilder { b.categories = c; return b } +func (b *VTodoBuilder) Rrule(r string) *VTodoBuilder { b.rrule = r; return b } +func (b *VTodoBuilder) Color(c string) *VTodoBuilder { b.color = c; return b } +func (b *VTodoBuilder) Sequence(s int) *VTodoBuilder { b.sequence = s; return b } +func (b *VTodoBuilder) Duration(d string) *VTodoBuilder { b.duration = d; return b } +func (b *VTodoBuilder) DtStamp(t time.Time) *VTodoBuilder { b.dtstamp = t; return b } +func (b *VTodoBuilder) Created(t time.Time) *VTodoBuilder { b.created = t; return b } +func (b *VTodoBuilder) LastModified(t time.Time) *VTodoBuilder { b.lastMod = t; return b } +func (b *VTodoBuilder) PercentComplete(p int) *VTodoBuilder { b.percentComplete = p; return b } +func (b *VTodoBuilder) ExtraProp(line string) *VTodoBuilder { b.extraProps = append(b.extraProps, line); return b } + +func (b *VTodoBuilder) RelatedToParent(uid string) *VTodoBuilder { + b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "PARENT", uid: uid}) + return b +} + +func (b *VTodoBuilder) RelatedToChild(uid string) *VTodoBuilder { + b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "CHILD", uid: uid}) + return b +} + +func (b *VTodoBuilder) AlarmAbsolute(triggerTime time.Time) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;VALUE=DATE-TIME:" + formatTime(triggerTime), + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func (b *VTodoBuilder) AlarmRelativeStart(duration string) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;RELATED=START:" + duration, + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func (b *VTodoBuilder) AlarmRelativeEnd(duration string) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;RELATED=END:" + duration, + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func formatTime(t time.Time) string { + return t.UTC().Format("20060102T150405Z") +} + +// Build returns the complete VCALENDAR string wrapping the VTODO. +func (b *VTodoBuilder) Build() string { + var sb strings.Builder + + sb.WriteString("BEGIN:VCALENDAR\r\n") + sb.WriteString("VERSION:2.0\r\n") + sb.WriteString("PRODID:-//Test//Test//EN\r\n") + sb.WriteString("BEGIN:VTODO\r\n") + sb.WriteString(fmt.Sprintf("UID:%s\r\n", b.uid)) + sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatTime(b.dtstamp))) + sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", b.summary)) + sb.WriteString(fmt.Sprintf("CREATED:%s\r\n", formatTime(b.created))) + sb.WriteString(fmt.Sprintf("LAST-MODIFIED:%s\r\n", formatTime(b.lastMod))) + + if b.description != "" { + sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", b.description)) + } + if b.priority > 0 { + sb.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", b.priority)) + } + if !b.due.IsZero() { + sb.WriteString(fmt.Sprintf("DUE:%s\r\n", formatTime(b.due))) + } + if !b.dtstart.IsZero() { + sb.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatTime(b.dtstart))) + } + if !b.completed.IsZero() { + sb.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatTime(b.completed))) + } + if b.status != "" { + sb.WriteString(fmt.Sprintf("STATUS:%s\r\n", b.status)) + } + if len(b.categories) > 0 { + sb.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.Join(b.categories, ","))) + } + if b.rrule != "" { + sb.WriteString(fmt.Sprintf("RRULE:%s\r\n", b.rrule)) + } + if b.color != "" { + sb.WriteString(fmt.Sprintf("X-APPLE-CALENDAR-COLOR:%s\r\n", b.color)) + } + if b.percentComplete > 0 { + sb.WriteString(fmt.Sprintf("PERCENT-COMPLETE:%d\r\n", b.percentComplete)) + } + if b.sequence > 0 { + sb.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", b.sequence)) + } + if b.duration != "" { + sb.WriteString(fmt.Sprintf("DURATION:%s\r\n", b.duration)) + } + for _, rel := range b.relatedTo { + if rel.reltype != "" { + sb.WriteString(fmt.Sprintf("RELATED-TO;RELTYPE=%s:%s\r\n", rel.reltype, rel.uid)) + } else { + sb.WriteString(fmt.Sprintf("RELATED-TO:%s\r\n", rel.uid)) + } + } + for _, alarm := range b.alarms { + sb.WriteString("BEGIN:VALARM\r\n") + sb.WriteString(alarm.trigger + "\r\n") + sb.WriteString(fmt.Sprintf("ACTION:%s\r\n", alarm.action)) + sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", alarm.description)) + sb.WriteString("END:VALARM\r\n") + } + for _, prop := range b.extraProps { + sb.WriteString(prop + "\r\n") + } + sb.WriteString("END:VTODO\r\n") + sb.WriteString("END:VCALENDAR\r\n") + + return sb.String() +} diff --git a/pkg/caldavtests/xml_helpers.go b/pkg/caldavtests/xml_helpers.go new file mode 100644 index 000000000..9bb405c57 --- /dev/null +++ b/pkg/caldavtests/xml_helpers.go @@ -0,0 +1,160 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-present Vikunja and contributors. All rights reserved. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "encoding/xml" + "net/http/httptest" + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Multistatus represents a WebDAV multistatus response (RFC 4918 §13) +type Multistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []Response `xml:"response"` +} + +// Response represents a single response within a multistatus +type Response struct { + Href string `xml:"href"` + Propstat []Propstat `xml:"propstat"` +} + +// Propstat groups a set of properties with a status +type Propstat struct { + Prop Prop `xml:"prop"` + Status string `xml:"status"` +} + +// Prop holds the actual property values returned by PROPFIND/REPORT. +type Prop struct { + // Standard DAV properties + DisplayName string `xml:"displayname,omitempty"` + ResourceType RawXML `xml:"resourcetype,omitempty"` + GetETag string `xml:"getetag,omitempty"` + GetCTag string `xml:"http://calendarserver.org/ns/ getctag,omitempty"` + + // CalDAV properties + CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data,omitempty"` + CalendarHomeSet RawXML `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set,omitempty"` + SupportedComponents RawXML `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set,omitempty"` + CalendarDescription string `xml:"urn:ietf:params:xml:ns:caldav calendar-description,omitempty"` + + // Principal properties + CurrentUserPrincipal RawXML `xml:"current-user-principal,omitempty"` + + // ACL properties + CurrentUserPrivilegeSet RawXML `xml:"current-user-privilege-set,omitempty"` + + // Catch-all for unexpected properties + InnerXML string `xml:",innerxml"` +} + +// RawXML captures raw XML content for properties we want to inspect flexibly +type RawXML struct { + InnerXML string `xml:",innerxml"` +} + +// parseMultistatus parses a WebDAV multistatus XML response body. +func parseMultistatus(t *testing.T, rec *httptest.ResponseRecorder) Multistatus { + t.Helper() + var ms Multistatus + err := xml.Unmarshal(rec.Body.Bytes(), &ms) + require.NoError(t, err, "Failed to parse multistatus XML. Body:\n%s", rec.Body.String()) + return ms +} + +// findResponse finds a response in a multistatus by href substring match. +func findResponse(t *testing.T, ms Multistatus, hrefSubstring string) Response { + t.Helper() + for _, r := range ms.Responses { + if strings.Contains(r.Href, hrefSubstring) { + return r + } + } + t.Fatalf("No response found with href containing %q in multistatus with %d responses", hrefSubstring, len(ms.Responses)) + return Response{} // unreachable +} + +// getSuccessfulProp returns the Prop from the first propstat with a 200 status. +func getSuccessfulProp(t *testing.T, r Response) Prop { + t.Helper() + for _, ps := range r.Propstat { + if strings.Contains(ps.Status, "200") { + return ps.Prop + } + } + t.Fatalf("No successful (200) propstat found in response for href %s", r.Href) + return Prop{} // unreachable +} + +// parseICalFromResponse parses iCalendar data from a response body. +func parseICalFromResponse(t *testing.T, rec *httptest.ResponseRecorder) *ics.Calendar { + t.Helper() + cal, err := ics.ParseCalendar(strings.NewReader(rec.Body.String())) + require.NoError(t, err, "Failed to parse iCalendar. Body:\n%s", rec.Body.String()) + return cal +} + +// parseICalFromString parses iCalendar data from a string (e.g., calendar-data property). +func parseICalFromString(t *testing.T, data string) *ics.Calendar { + t.Helper() + cal, err := ics.ParseCalendar(strings.NewReader(data)) + require.NoError(t, err, "Failed to parse iCalendar data:\n%s", data) + return cal +} + +// getVTodo extracts the first VTODO component from a calendar. +func getVTodo(t *testing.T, cal *ics.Calendar) *ics.VTodo { + t.Helper() + for _, comp := range cal.Components { + if vtodo, ok := comp.(*ics.VTodo); ok { + return vtodo + } + } + t.Fatal("No VTODO component found in calendar") + return nil // unreachable +} + +// getVTodoProperty extracts a property value from a VTODO. +func getVTodoProperty(vtodo *ics.VTodo, prop ics.ComponentProperty) string { + p := vtodo.GetProperty(prop) + if p == nil { + return "" + } + return p.Value +} + +// assertResponseStatus asserts the HTTP status code. +func assertResponseStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) { + t.Helper() + assert.Equal(t, expectedStatus, rec.Code, "Response body:\n%s", rec.Body.String()) +} + +// assertMultistatusHasResponses asserts that a 207 response contains the expected number of responses. +func assertMultistatusHasResponses(t *testing.T, rec *httptest.ResponseRecorder, expectedCount int) Multistatus { + t.Helper() + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + assert.Len(t, ms.Responses, expectedCount, "Expected %d responses in multistatus, got %d.\nBody:\n%s", expectedCount, len(ms.Responses), rec.Body.String()) + return ms +}