From 7830a9c3ea004f0bdc5660e5ac0736237cce536c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:39:02 +0100 Subject: [PATCH] test(caldav): add client compatibility and bug reproduction tests --- pkg/caldavtests/bugs_test.go | 51 ++++++ pkg/caldavtests/client_compat_test.go | 215 ++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 pkg/caldavtests/bugs_test.go create mode 100644 pkg/caldavtests/client_compat_test.go diff --git a/pkg/caldavtests/bugs_test.go b/pkg/caldavtests/bugs_test.go new file mode 100644 index 000000000..a75b28128 --- /dev/null +++ b/pkg/caldavtests/bugs_test.go @@ -0,0 +1,51 @@ +// 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 ( + "testing" +) + +// TestBugs contains tests that reproduce specific bugs reported by users. +// Each test references the GitHub issue it reproduces. +// These tests are expected to FAIL until the bug is fixed. +// +// To add a new bug reproduction test: +// 1. Create a new t.Run with the issue number in the name +// 2. Reproduce the exact CalDAV request sequence from the bug report +// 3. Assert what the correct behavior SHOULD be (not what it currently does) +// 4. The test will fail until the bug is fixed — this is expected and good + +func TestBugs(t *testing.T) { + // Template for adding bug reproductions: + // + // t.Run("GitHub_Issue_NNNN_short_description", func(t *testing.T) { + // e := setupTestEnv(t) + // + // // Reproduce the steps from the issue... + // vtodo := NewVTodo("issue-NNNN", "...").Build() + // rec := caldavPUT(t, e, "/dav/projects/36/issue-NNNN.ics", vtodo) + // + // // Assert the expected (correct) behavior + // assert.Equal(t, 201, rec.Code) + // }) + + t.Run("placeholder_no_bugs_yet", func(t *testing.T) { + // Remove this placeholder once real bug tests are added + t.Skip("No bug reproductions added yet") + }) +} diff --git a/pkg/caldavtests/client_compat_test.go b/pkg/caldavtests/client_compat_test.go new file mode 100644 index 000000000..f7a103e87 --- /dev/null +++ b/pkg/caldavtests/client_compat_test.go @@ -0,0 +1,215 @@ +// 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" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientDAVx5Flow(t *testing.T) { + t.Run("Full DAVx5 sync flow", func(t *testing.T) { + e := setupTestEnv(t) + + // Step 1: Discover principal + // DAVx5 sends PROPFIND to the server root or well-known URL + rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal) + assert.True(t, rec.Code == 207 || rec.Code == 301, + "Step 1: PROPFIND /dav/ should return 207 or redirect. Got %d", rec.Code) + + // Step 2: Get calendar-home-set from principal + rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + assertResponseStatus(t, rec, 207) + assert.Contains(t, rec.Body.String(), "calendar-home-set", + "Step 2: Principal should advertise calendar-home-set") + + // Step 3: List calendars + rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + assert.GreaterOrEqual(t, len(ms.Responses), 2, + "Step 3: Should list calendars") + + // Step 4: Check CTag for a specific calendar + rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + + // Step 5: Full sync — calendar-query to get all task ETags + rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + assertResponseStatus(t, rec, 207) + ms = parseMultistatus(t, rec) + assert.Greater(t, len(ms.Responses), 0, + "Step 5: calendar-query should return tasks") + + // Collect hrefs for multiget + var hrefs []string + for _, r := range ms.Responses { + if strings.HasSuffix(r.Href, ".ics") { + hrefs = append(hrefs, r.Href) + } + } + + // Step 6: Multiget to fetch specific tasks + if len(hrefs) > 0 { + body := ReportCalendarMultiget(hrefs[:1]...) // Just fetch first task + rec = caldavREPORT(t, e, "/dav/projects/36", body) + assertResponseStatus(t, rec, 207) + ms = parseMultistatus(t, rec) + assert.Len(t, ms.Responses, 1, + "Step 6: multiget should return requested task") + } + + // Step 7: Push a local change via PUT + vtodo := NewVTodo("davx5-sync-test", "DAVx5 Synced Task"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + Build() + rec = caldavPUT(t, e, "/dav/projects/36/davx5-sync-test.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code, + "Step 7: PUT should create the task") + }) +} + +func TestClientThunderbirdFlow(t *testing.T) { + t.Run("Thunderbird discovery and initial sync", func(t *testing.T) { + e := setupTestEnv(t) + + // Step 1: Thunderbird starts with OPTIONS to check DAV support + rec := caldavOPTIONS(t, e, "/dav/") + assert.Equal(t, http.StatusOK, rec.Code, + "Step 1: OPTIONS should succeed") + davHeader := rec.Header().Get("DAV") + assert.NotEmpty(t, davHeader, + "Step 1: Should have DAV header") + + // Step 2: PROPFIND on well-known for principal + rec = caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + assert.True(t, rec.Code == 207 || rec.Code == 301 || rec.Code == 302, + "Step 2: well-known should respond. Got %d", rec.Code) + + // Step 3: PROPFIND principal for calendar-home-set + rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + assertResponseStatus(t, rec, 207) + + // Step 4: Thunderbird checks current-user-privilege-set to know if it can write + // RFC 3744 §5.4 (rfc3744.txt line 1158) + rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCurrentUserPrivilegeSet) + // This may return 207 with or without the property — document the behavior + assert.True(t, rec.Code == 207 || rec.Code == 200, + "Step 4: PROPFIND for privileges should not error. Got %d", rec.Code) + + // Step 5: List calendars + rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + + // Step 6: Sync via calendar-query + rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + assertResponseStatus(t, rec, 207) + }) +} + +func TestClientTasksOrgSubtasks(t *testing.T) { + t.Run("Tasks.org subtask sync: child-only RELATED-TO", func(t *testing.T) { + // Tasks.org behavior: + // - Child tasks include RELATED-TO;RELTYPE=PARENT: + // - Parent tasks have NO RELATED-TO at all + // - Tasks may arrive in any order + // - On re-sync, parent is sent again without RELATED-TO + + e := setupTestEnv(t) + + // Round 1: Initial sync — parent first, then children + parent := NewVTodo("tasks-org-parent", "Buy groceries").Build() + rec := caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parent) + require.Equal(t, 201, rec.Code) + + child1 := NewVTodo("tasks-org-child-1", "Buy milk"). + RelatedToParent("tasks-org-parent").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1) + require.Equal(t, 201, rec.Code) + + child2 := NewVTodo("tasks-org-child-2", "Buy eggs"). + RelatedToParent("tasks-org-parent").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-2.ics", child2) + require.Equal(t, 201, rec.Code) + + // Verify parent shows children + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics") + body := rec.Body.String() + assert.Contains(t, body, "tasks-org-child-1") + assert.Contains(t, body, "tasks-org-child-2") + + // Round 2: Re-sync — parent updated (title change), still no RELATED-TO + parentUpdated := NewVTodo("tasks-org-parent", "Buy groceries (updated list)").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parentUpdated) + require.True(t, rec.Code >= 200 && rec.Code < 300) + + // Verify children are still linked after parent re-sync + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics") + body = rec.Body.String() + assert.Contains(t, body, "Buy groceries (updated list)", + "Parent title should be updated") + assert.Contains(t, body, "tasks-org-child-1", + "Child 1 relation should survive parent re-sync") + assert.Contains(t, body, "tasks-org-child-2", + "Child 2 relation should survive parent re-sync") + + // Round 3: Complete child via PUT with STATUS:COMPLETED + child1Done := NewVTodo("tasks-org-child-1", "Buy milk"). + RelatedToParent("tasks-org-parent"). + Status("COMPLETED"). + Completed(time.Now().UTC()). + Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1Done) + require.True(t, rec.Code >= 200 && rec.Code < 300) + + // Verify child is completed + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-child-1.ics") + assert.Contains(t, rec.Body.String(), "STATUS:COMPLETED") + }) + + t.Run("Tasks.org subtask sync: children arrive before parent", func(t *testing.T) { + e := setupTestEnv(t) + + // Children arrive first (reverse order) + child := NewVTodo("tasks-rev-child", "Subtask"). + RelatedToParent("tasks-rev-parent").Build() + rec := caldavPUT(t, e, "/dav/projects/36/tasks-rev-child.ics", child) + require.Equal(t, 201, rec.Code) + + // Parent arrives later — no RELATED-TO + parent := NewVTodo("tasks-rev-parent", "Main Task").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-rev-parent.ics", parent) + require.Equal(t, 201, rec.Code) + + // Verify bidirectional relations + rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-parent.ics") + assert.Contains(t, rec.Body.String(), "SUMMARY:Main Task", + "Parent should have real title, not DUMMY") + assert.Contains(t, rec.Body.String(), "tasks-rev-child", + "Parent should show child relation") + + rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-child.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:tasks-rev-parent") + }) +}