From 7e7d168e452fed2bdfcef65d29fa09fd4cf9296f Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:40:38 +0100 Subject: [PATCH] =?UTF-8?q?test(caldav):=20add=20VTODO=20field=20round-tri?= =?UTF-8?q?p=20tests=20(RFC=205545=20=C2=A73.6.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive round-trip tests for VTODO properties: - Core fields: SUMMARY, DESCRIPTION, DUE, DTSTART, PRIORITY, COMPLETED, STATUS, CATEGORIES - VALARM: absolute, relative-to-start, relative-to-end triggers - COLOR via X-APPLE-CALENDAR-COLOR - RRULE: DAILY, WEEKLY, MONTHLY frequencies - Priority mapping: all 9 CalDAV levels including lossy mappings - DURATION and DTSTART+DUE interaction --- pkg/caldavtests/vtodo_roundtrip_test.go | 395 ++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 pkg/caldavtests/vtodo_roundtrip_test.go diff --git a/pkg/caldavtests/vtodo_roundtrip_test.go b/pkg/caldavtests/vtodo_roundtrip_test.go new file mode 100644 index 000000000..8f48f2f88 --- /dev/null +++ b/pkg/caldavtests/vtodo_roundtrip_test.go @@ -0,0 +1,395 @@ +// 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" + "time" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVTodoRoundTrip(t *testing.T) { + // Helper: PUT a VTODO, GET it back, parse the VTODO + putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300, + "PUT failed with status %d. Body:\n%s", rec.Code, rec.Body.String()) + + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code, "GET failed. Body:\n%s", rec2.Body.String()) + + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("SUMMARY round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.12 (rfc5545.txt line 5179) + vtodo := NewVTodo("rt-summary", "My Task Summary").Build() + result := putAndGet(t, "rt-summary", "/dav/projects/36/rt-summary.ics", vtodo) + assert.Equal(t, "My Task Summary", getVTodoProperty(result, ics.ComponentPropertySummary)) + }) + + t.Run("DESCRIPTION round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.5 (rfc5545.txt line 4688) + vtodo := NewVTodo("rt-desc", "Desc Test"). + Description("This is a detailed description"). + Build() + result := putAndGet(t, "rt-desc", "/dav/projects/36/rt-desc.ics", vtodo) + desc := getVTodoProperty(result, ics.ComponentPropertyDescription) + assert.Contains(t, desc, "This is a detailed description") + }) + + t.Run("DESCRIPTION with newlines round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-desc-nl", "Desc Newline Test"). + Description("Line 1\\nLine 2\\nLine 3"). + Build() + result := putAndGet(t, "rt-desc-nl", "/dav/projects/36/rt-desc-nl.ics", vtodo) + desc := getVTodoProperty(result, ics.ComponentPropertyDescription) + // Should preserve the newline structure + assert.True(t, strings.Contains(desc, "Line 1") && strings.Contains(desc, "Line 2"), + "Description should preserve multi-line content. Got: %s", desc) + }) + + t.Run("DUE round-trips", func(t *testing.T) { + // RFC 5545 §3.8.2.3 (rfc5545.txt line 5353) + due := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC) + vtodo := NewVTodo("rt-due", "Due Test").Due(due).Build() + result := putAndGet(t, "rt-due", "/dav/projects/36/rt-due.ics", vtodo) + + dueStr := getVTodoProperty(result, ics.ComponentPropertyDue) + assert.Contains(t, dueStr, "20240615", + "DUE date should be preserved. Got: %s", dueStr) + }) + + t.Run("DTSTART round-trips", func(t *testing.T) { + // RFC 5545 §3.8.2.4 (rfc5545.txt line 5415) + start := time.Date(2024, 5, 1, 9, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-dtstart", "Start Test").DtStart(start).Build() + result := putAndGet(t, "rt-dtstart", "/dav/projects/36/rt-dtstart.ics", vtodo) + + startStr := getVTodoProperty(result, ics.ComponentPropertyDtStart) + assert.Contains(t, startStr, "20240501", + "DTSTART should be preserved. Got: %s", startStr) + }) + + t.Run("PRIORITY round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.9 (rfc5545.txt line 4956) + // CalDAV priority 1 = Vikunja priority 5 (highest) + // The round-trip may map through Vikunja's priority system + // Vikunja maps: CalDAV 1→Vikunja 5→CalDAV 1 + vtodo := NewVTodo("rt-priority-1", "Priority 1 Test").Priority(1).Build() + result := putAndGet(t, "rt-priority-1", "/dav/projects/36/rt-priority-1.ics", vtodo) + assert.Equal(t, "1", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 1 (highest) should round-trip") + }) + + t.Run("PRIORITY 5 round-trips as 5", func(t *testing.T) { + // CalDAV 5 = Vikunja 2 (medium) → CalDAV 5 + vtodo := NewVTodo("rt-priority-5", "Priority 5 Test").Priority(5).Build() + result := putAndGet(t, "rt-priority-5", "/dav/projects/36/rt-priority-5.ics", vtodo) + assert.Equal(t, "5", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 5 (medium) should round-trip") + }) + + t.Run("PRIORITY 9 round-trips as 9", func(t *testing.T) { + // CalDAV 9 = Vikunja 1 (low) → CalDAV 9 + vtodo := NewVTodo("rt-priority-9", "Priority 9 Test").Priority(9).Build() + result := putAndGet(t, "rt-priority-9", "/dav/projects/36/rt-priority-9.ics", vtodo) + assert.Equal(t, "9", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 9 (lowest) should round-trip") + }) + + t.Run("PRIORITY 0 (unset) round-trips", func(t *testing.T) { + // Priority 0 means unset — should not appear in output + vtodo := NewVTodo("rt-priority-0", "No Priority Test").Build() + result := putAndGet(t, "rt-priority-0", "/dav/projects/36/rt-priority-0.ics", vtodo) + priorityStr := getVTodoProperty(result, ics.ComponentPropertyPriority) + assert.True(t, priorityStr == "" || priorityStr == "0", + "Priority 0 (unset) should round-trip as empty or 0. Got: %s", priorityStr) + }) + + t.Run("COMPLETED and STATUS:COMPLETED round-trip", func(t *testing.T) { + // RFC 5545 §3.8.2.1 (rfc5545.txt line 5240) + completed := time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-completed", "Completed Test"). + Completed(completed). + Status("COMPLETED"). + Build() + result := putAndGet(t, "rt-completed", "/dav/projects/36/rt-completed.ics", vtodo) + + compStr := getVTodoProperty(result, ics.ComponentPropertyCompleted) + assert.Contains(t, compStr, "20240315", + "COMPLETED date should be preserved. Got: %s", compStr) + + statusStr := getVTodoProperty(result, ics.ComponentPropertyStatus) + assert.Equal(t, "COMPLETED", statusStr, + "STATUS should be COMPLETED when task is done") + }) + + t.Run("CATEGORIES round-trip", func(t *testing.T) { + // RFC 5545 §3.8.1.2 (rfc5545.txt line 4520) + vtodo := NewVTodo("rt-categories", "Categories Test"). + Categories("work", "urgent", "bug"). + Build() + result := putAndGet(t, "rt-categories", "/dav/projects/36/rt-categories.ics", vtodo) + + catProp := result.GetProperty(ics.ComponentPropertyCategories) + require.NotNil(t, catProp, "CATEGORIES property should be present") + + catStr := catProp.Value + assert.Contains(t, catStr, "work", "Should contain 'work' category") + assert.Contains(t, catStr, "urgent", "Should contain 'urgent' category") + assert.Contains(t, catStr, "bug", "Should contain 'bug' category") + }) + + t.Run("Single CATEGORY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-cat-single", "Single Category"). + Categories("solo-label"). + Build() + result := putAndGet(t, "rt-cat-single", "/dav/projects/36/rt-cat-single.ics", vtodo) + + catProp := result.GetProperty(ics.ComponentPropertyCategories) + require.NotNil(t, catProp, "CATEGORIES property should be present") + assert.Contains(t, catProp.Value, "solo-label") + }) + + t.Run("VALARM with absolute trigger round-trips", func(t *testing.T) { + // RFC 5545 §3.8.6 (rfc5545.txt line 7352) + alarmTime := time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-alarm-abs", "Alarm Absolute Test"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + AlarmAbsolute(alarmTime). + Build() + result := putAndGet(t, "rt-alarm-abs", "/dav/projects/36/rt-alarm-abs.ics", vtodo) + + // Check that the VTODO contains a VALARM + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + assert.Contains(t, body, "TRIGGER", "VALARM should have a TRIGGER") + }) + + t.Run("VALARM with relative-to-start trigger round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-alarm-rel-start", "Alarm Relative Start"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + AlarmRelativeStart("-PT15M"). + Build() + result := putAndGet(t, "rt-alarm-rel-start", "/dav/projects/36/rt-alarm-rel-start.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + assert.Contains(t, body, "TRIGGER", "VALARM should have a TRIGGER") + }) + + t.Run("VALARM with relative-to-end trigger round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-alarm-rel-end", "Alarm Relative End"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + AlarmRelativeEnd("-PT30M"). + Build() + result := putAndGet(t, "rt-alarm-rel-end", "/dav/projects/36/rt-alarm-rel-end.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + }) + + t.Run("COLOR via X-APPLE-CALENDAR-COLOR round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-color", "Color Test"). + Color("#ff0000FF"). + Build() + result := putAndGet(t, "rt-color", "/dav/projects/36/rt-color.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + // Vikunja should preserve the color in at least one of the color properties + colorFound := strings.Contains(body, "ff0000") || + strings.Contains(body, "FF0000") || + strings.Contains(body, "#ff0000") + assert.True(t, colorFound, + "Color should be preserved in some form. Got:\n%s", body) + }) +} + +func TestVTodoRRuleRoundTrip(t *testing.T) { + // RFC 5545 §3.8.5.3 (rfc5545.txt line 6794) + + putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300, + "PUT failed with %d", rec.Code) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("RRULE FREQ=DAILY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-daily", "Daily Repeat"). + Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=DAILY;INTERVAL=1"). + Build() + result := putAndGet(t, "rt-rrule-daily", "/dav/projects/36/rt-rrule-daily.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "DAILY", "Should contain DAILY frequency") + }) + + t.Run("RRULE FREQ=WEEKLY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-weekly", "Weekly Repeat"). + Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=WEEKLY;INTERVAL=2"). + Build() + result := putAndGet(t, "rt-rrule-weekly", "/dav/projects/36/rt-rrule-weekly.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "WEEKLY", "Should contain WEEKLY frequency") + }) + + t.Run("RRULE FREQ=MONTHLY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-monthly", "Monthly Repeat"). + Due(time.Date(2024, 6, 15, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=MONTHLY;BYMONTHDAY=15"). + Build() + result := putAndGet(t, "rt-rrule-monthly", "/dav/projects/36/rt-rrule-monthly.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "MONTHLY", "Should contain MONTHLY frequency") + }) +} + +func TestVTodoPriorityMapping(t *testing.T) { + // RFC 5545 §3.8.1.9 (rfc5545.txt line 4956): + // "A value of 0 specifies an undefined priority. A value of 1 + // is the highest priority. A value of 9 is the lowest priority." + // + // Vikunja mapping (pkg/caldav/priority.go): + // CalDAV 1 → Vikunja 5 → CalDAV 1 (DO NOW) + // CalDAV 2 → Vikunja 4 → CalDAV 2 (Urgent) + // CalDAV 3 → Vikunja 3 → CalDAV 3 (High) + // CalDAV 4 → Vikunja 3 → CalDAV 3 (maps to High, LOSSY) + // CalDAV 5 → Vikunja 2 → CalDAV 5 (Medium) + // CalDAV 6-8 → Vikunja 1 → CalDAV 9 (maps to Low, LOSSY) + // CalDAV 9 → Vikunja 1 → CalDAV 9 (Low) + + putAndGetPriority := func(t *testing.T, uid string, inputPriority int) string { + t.Helper() + e := setupTestEnv(t) + vtodo := NewVTodo(uid, "Priority "+uid).Priority(inputPriority).Build() + path := "/dav/projects/36/" + uid + ".ics" + rec := caldavPUT(t, e, path, vtodo) + require.True(t, rec.Code >= 200 && rec.Code < 300) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodoProperty(getVTodo(t, cal), ics.ComponentPropertyPriority) + } + + // Lossless round-trips + t.Run("Priority 1 (highest) round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "1", putAndGetPriority(t, "p1", 1)) + }) + t.Run("Priority 2 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "2", putAndGetPriority(t, "p2", 2)) + }) + t.Run("Priority 3 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "3", putAndGetPriority(t, "p3", 3)) + }) + t.Run("Priority 5 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "5", putAndGetPriority(t, "p5", 5)) + }) + t.Run("Priority 9 (lowest) round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "9", putAndGetPriority(t, "p9", 9)) + }) + + // Lossy mappings (document the behavior) + t.Run("Priority 4 maps to 3 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p4", 4) + assert.Equal(t, "3", result, + "CalDAV priority 4 maps to Vikunja 3 (High), which exports as CalDAV 3") + }) + t.Run("Priority 6 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p6", 6) + assert.Equal(t, "9", result, + "CalDAV priority 6 maps to Vikunja 1 (Low), which exports as CalDAV 9") + }) + t.Run("Priority 7 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p7", 7) + assert.Equal(t, "9", result) + }) + t.Run("Priority 8 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p8", 8) + assert.Equal(t, "9", result) + }) +} + +func TestVTodoDurationRoundTrip(t *testing.T) { + // RFC 5545 §3.8.2.5 (rfc5545.txt line 5495): + // "In a VTODO calendar component the property may be used to + // specify a positive duration of time that the to-do is expected + // to take for its completion." + + putAndGet := func(t *testing.T, uid, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + path := "/dav/projects/36/" + uid + ".ics" + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("DTSTART + DURATION computes end date", func(t *testing.T) { + // When DTSTART and DURATION are specified, Vikunja should compute + // EndDate = DTSTART + DURATION (pkg/caldav/parsing.go:412-414) + vtodo := NewVTodo("rt-duration", "Duration Test"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Duration("PT2H"). + Build() + result := putAndGet(t, "rt-duration", vtodo) + + // Vikunja stores DTSTART and EndDate (DTSTART+DURATION) + // On export, it may output DTSTART and DTEND, or DTSTART and DURATION + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + hasEnd := strings.Contains(body, "DTEND") || strings.Contains(body, "DURATION") + assert.True(t, hasEnd, + "Should preserve end time information (via DTEND or DURATION). Got:\n%s", body) + }) + + t.Run("DTSTART + DUE are both preserved", func(t *testing.T) { + vtodo := NewVTodo("rt-start-due", "Start and Due"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Due(time.Date(2024, 6, 15, 17, 0, 0, 0, time.UTC)). + Build() + result := putAndGet(t, "rt-start-due", vtodo) + + startStr := getVTodoProperty(result, ics.ComponentPropertyDtStart) + dueStr := getVTodoProperty(result, ics.ComponentPropertyDue) + assert.Contains(t, startStr, "20240601", "DTSTART should be preserved") + assert.Contains(t, dueStr, "20240615", "DUE should be preserved") + }) +}