diff --git a/pkg/caldavtests/report_test.go b/pkg/caldavtests/report_test.go new file mode 100644 index 000000000..02d5329e3 --- /dev/null +++ b/pkg/caldavtests/report_test.go @@ -0,0 +1,233 @@ +// 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 ( + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportCalendarQuery(t *testing.T) { + // RFC 4791 §7.8 (rfc4791.txt line 1967): + // "The CALDAV:calendar-query REPORT performs a search for all calendar + // object resources that match a specified filter." + + t.Run("calendar-query returns 207 Multi-Status", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + }) + + t.Run("calendar-query returns all tasks in project", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Project 36 has 5 tasks in fixtures (40, 41, 42, 43, 45) + assert.Len(t, ms.Responses, 5, + "Should return all 5 tasks from project 36") + }) + + t.Run("calendar-query responses include ETag", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.GetETag, + "Each response should include an ETag. Href: %s", r.Href) + } + }) + + t.Run("calendar-query responses include valid calendar-data", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for i, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.CalendarData, + "Response %d should include calendar-data. Href: %s", i, r.Href) + + // Each calendar-data should be parseable iCalendar + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + + uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId) + assert.NotEmpty(t, uid, "VTODO %d should have a UID", i) + + summary := getVTodoProperty(vtodo, ics.ComponentPropertySummary) + assert.NotEmpty(t, summary, "VTODO %d should have a SUMMARY", i) + } + }) + + t.Run("calendar-query response hrefs point to correct resources", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for _, r := range ms.Responses { + // Each href should be a valid task URL containing the project ID and .ics + assert.Contains(t, r.Href, "/dav/projects/", + "Href should contain /dav/projects/") + assert.True(t, strings.HasSuffix(r.Href, ".ics"), + "Href should end with .ics. Got: %s", r.Href) + } + }) + + t.Run("calendar-query on nonexistent project returns error", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/99999", ReportCalendarQuery) + + // Should be 404 or similar error, not 200/207 + assert.NotEqual(t, 207, rec.Code, + "REPORT on nonexistent project should not return 207") + }) + + t.Run("calendar-query on project 38 returns correct task count", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/38", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + // Project 38 has 2 tasks (44, 46) + assert.Len(t, ms.Responses, 2, + "Project 38 should have 2 tasks") + }) +} + +func TestReportCalendarMultiget(t *testing.T) { + // RFC 4791 §7.9 (rfc4791.txt line 3479): + // "The CALDAV:calendar-multiget REPORT is used to retrieve specific + // calendar object resources from within a collection." + + t.Run("calendar-multiget returns requested tasks", func(t *testing.T) { + e := setupTestEnv(t) + + // Request two specific tasks from project 36 + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + "/dav/projects/36/uid-caldav-test-parent-task.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + assert.Len(t, ms.Responses, 2, + "Should return exactly the 2 requested tasks") + }) + + t.Run("calendar-multiget returns calendar-data for each task", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + require.Len(t, ms.Responses, 1) + + prop := getSuccessfulProp(t, ms.Responses[0]) + assert.NotEmpty(t, prop.CalendarData, "Should include calendar-data") + assert.NotEmpty(t, prop.GetETag, "Should include ETag") + + // Verify the returned data matches the requested task + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)) + }) + + t.Run("calendar-multiget with nonexistent href returns 404 for that href", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + "/dav/projects/36/nonexistent-uid.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Should still return results — the existing task should be there + // The nonexistent one might be absent or have a 404 propstat + foundExisting := false + for _, r := range ms.Responses { + if strings.Contains(r.Href, "uid-caldav-test") { + foundExisting = true + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.CalendarData) + } + } + assert.True(t, foundExisting, + "Should still return the existing task even when one href is invalid") + }) + + t.Run("calendar-multiget with empty href list returns empty", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget() // No hrefs + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + // Should return 207 with no responses, or possibly an error + assert.True(t, rec.Code == 207 || rec.Code >= 400, + "Empty multiget should return 207 (empty) or an error, got %d", rec.Code) + }) + + t.Run("calendar-multiget ETags match PROPFIND ETags", func(t *testing.T) { + e := setupTestEnv(t) + + // Get ETag via PROPFIND + propfindRec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + assertResponseStatus(t, propfindRec, 207) + propfindMs := parseMultistatus(t, propfindRec) + propfindEtag := getSuccessfulProp(t, propfindMs.Responses[0]).GetETag + + // Get ETag via multiget REPORT + body := ReportCalendarMultiget("/dav/projects/36/uid-caldav-test.ics") + reportRec := caldavREPORT(t, e, "/dav/projects/36", body) + assertResponseStatus(t, reportRec, 207) + reportMs := parseMultistatus(t, reportRec) + reportEtag := getSuccessfulProp(t, reportMs.Responses[0]).GetETag + + // ETags should match between PROPFIND and REPORT + assert.Equal(t, propfindEtag, reportEtag, + "ETag from PROPFIND and calendar-multiget should match") + }) +}