// 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" "time" "github.com/stretchr/testify/assert" ) func TestCRUDCreate(t *testing.T) { // RFC 4791 §5.3.2 (rfc4791.txt line 1358): // "A PUT request on a calendar collection creates a new calendar // object resource when the Request-URI does not identify an // existing resource." t.Run("PUT new task returns 201 Created", func(t *testing.T) { e := setupTestEnv(t) vtodo := NewVTodo("test-create-uid", "Test Create Task"). Due(time.Date(2024, 3, 1, 15, 0, 0, 0, time.UTC)). Build() rec := caldavPUT(t, e, "/dav/projects/36/test-create-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code, "PUT of new resource should return 201. Body:\n%s", rec.Body.String()) }) t.Run("PUT new task sets ETag in response", func(t *testing.T) { e := setupTestEnv(t) vtodo := NewVTodo("test-etag-uid", "Test ETag Task").Build() rec := caldavPUT(t, e, "/dav/projects/36/test-etag-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code) etag := rec.Header().Get("ETag") assert.NotEmpty(t, etag, "PUT response should include ETag header for the newly created resource") }) t.Run("Created task is retrievable via GET", func(t *testing.T) { e := setupTestEnv(t) vtodo := NewVTodo("test-roundtrip-uid", "Roundtrip Test Task"). Description("A task created via CalDAV PUT"). Priority(3). Build() rec := caldavPUT(t, e, "/dav/projects/36/test-roundtrip-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code) // Now GET the task back rec2 := caldavGET(t, e, "/dav/projects/36/test-roundtrip-uid.ics") assert.Equal(t, http.StatusOK, rec2.Code) body := rec2.Body.String() assert.Contains(t, body, "BEGIN:VCALENDAR") assert.Contains(t, body, "BEGIN:VTODO") assert.Contains(t, body, "UID:test-roundtrip-uid") assert.Contains(t, body, "SUMMARY:Roundtrip Test Task") }) t.Run("PUT with invalid VCALENDAR returns error", func(t *testing.T) { t.Skip("Known bug: parse errors propagate as 500 instead of 400 — caldav-go does not map parse failures to 4xx") e := setupTestEnv(t) rec := caldavPUT(t, e, "/dav/projects/36/bad-task.ics", "not a valid vcalendar") // Should fail with a 4xx error assert.GreaterOrEqual(t, rec.Code, 400, "PUT with invalid VCALENDAR should return 4xx error") assert.Less(t, rec.Code, 500, "PUT with invalid VCALENDAR should not be a server error") }) t.Run("PUT to nonexistent project returns 404", func(t *testing.T) { e := setupTestEnv(t) vtodo := NewVTodo("test-noproject-uid", "No Project Task").Build() rec := caldavPUT(t, e, "/dav/projects/99999/test-noproject-uid.ics", vtodo) assert.Equal(t, http.StatusNotFound, rec.Code, "PUT to nonexistent project should return 404") }) t.Run("PUT task with all supported fields", func(t *testing.T) { e := setupTestEnv(t) vtodo := NewVTodo("test-allfields-uid", "All Fields Task"). Description("Full description\\nwith newlines"). Priority(1). // Highest priority in CalDAV Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). Categories("work", "urgent"). Status("IN-PROCESS"). AlarmAbsolute(time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC)). Build() rec := caldavPUT(t, e, "/dav/projects/36/test-allfields-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code, "PUT with all fields should succeed. Body:\n%s", rec.Body.String()) }) } func TestCRUDRead(t *testing.T) { t.Run("GET existing task returns VCALENDAR", func(t *testing.T) { e := setupTestEnv(t) // Task 40 (uid-caldav-test) exists in project 36 from fixtures rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, "BEGIN:VCALENDAR") assert.Contains(t, body, "BEGIN:VTODO") assert.Contains(t, body, "UID:uid-caldav-test") assert.Contains(t, body, "SUMMARY:Title Caldav Test") assert.Contains(t, body, "END:VTODO") assert.Contains(t, body, "END:VCALENDAR") }) t.Run("GET returns correct Content-Type", func(t *testing.T) { e := setupTestEnv(t) rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusOK, rec.Code) contentType := rec.Header().Get("Content-Type") // Should be text/calendar per RFC 4791 assert.Contains(t, contentType, "text/calendar", "GET on .ics resource should return Content-Type: text/calendar, got: %s", contentType) }) t.Run("GET returns ETag header", func(t *testing.T) { e := setupTestEnv(t) rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusOK, rec.Code) etag := rec.Header().Get("ETag") assert.NotEmpty(t, etag, "GET response should include ETag header") }) t.Run("GET nonexistent task returns 404", func(t *testing.T) { e := setupTestEnv(t) rec := caldavGET(t, e, "/dav/projects/36/nonexistent-uid.ics") assert.Equal(t, http.StatusNotFound, rec.Code, "GET nonexistent task should return 404") }) t.Run("GET project returns all tasks as VCALENDAR", func(t *testing.T) { e := setupTestEnv(t) rec := caldavGET(t, e, "/dav/projects/36") assert.Equal(t, http.StatusOK, rec.Code) body := rec.Body.String() assert.Contains(t, body, "BEGIN:VCALENDAR") assert.Contains(t, body, "X-WR-CALNAME:Project 36 for Caldav tests") // Should contain multiple VTODOs assert.Contains(t, body, "uid-caldav-test") }) t.Run("GET task with .ics suffix works", func(t *testing.T) { e := setupTestEnv(t) rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusOK, rec.Code) }) t.Run("GET task without .ics suffix works", func(t *testing.T) { e := setupTestEnv(t) // Some clients may not include .ics suffix rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test") // This might 404 depending on implementation — document the behavior // Either 200 or 404 is acceptable, but should be consistent assert.True(t, rec.Code == http.StatusOK || rec.Code == http.StatusNotFound, "GET without .ics should return 200 or 404, got %d", rec.Code) }) } func TestCRUDUpdate(t *testing.T) { t.Run("PUT to existing task updates it", func(t *testing.T) { e := setupTestEnv(t) // First create vtodo := NewVTodo("test-update-uid", "Original Title").Build() rec := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code) // Then update vtodoUpdated := NewVTodo("test-update-uid", "Updated Title"). Description("Now with a description"). Build() rec2 := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodoUpdated) // Update should return 200 or 204 (not 201) assert.True(t, rec2.Code == http.StatusOK || rec2.Code == http.StatusNoContent || rec2.Code == http.StatusCreated, // Some implementations return 201 for updates too "PUT update should return 200, 204, or 201, got %d", rec2.Code) // Verify the update took effect rec3 := caldavGET(t, e, "/dav/projects/36/test-update-uid.ics") assert.Equal(t, http.StatusOK, rec3.Code) assert.Contains(t, rec3.Body.String(), "Updated Title") assert.Contains(t, rec3.Body.String(), "Now with a description") }) t.Run("PUT update changes ETag", func(t *testing.T) { e := setupTestEnv(t) // Create vtodo := NewVTodo("test-etag-change-uid", "ETag Change Test").Build() rec1 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec1.Code) etag1 := rec1.Header().Get("ETag") // ETag uses second-precision timestamps, so we must wait to ensure a different value time.Sleep(time.Second) // Update vtodoUpdated := NewVTodo("test-etag-change-uid", "ETag Change Test Updated").Build() rec2 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodoUpdated) etag2 := rec2.Header().Get("ETag") // ETags should differ after update if etag1 != "" && etag2 != "" { assert.NotEqual(t, etag1, etag2, "ETag should change after update. Before: %s, After: %s", etag1, etag2) } }) t.Run("PUT update preserves UID", func(t *testing.T) { e := setupTestEnv(t) // Create vtodo := NewVTodo("test-preserve-uid", "Preserve UID Test").Build() rec := caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodo) assert.Equal(t, http.StatusCreated, rec.Code) // Update with different title but same UID vtodoUpdated := NewVTodo("test-preserve-uid", "Updated Preserve UID").Build() caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodoUpdated) // Verify UID is preserved rec3 := caldavGET(t, e, "/dav/projects/36/test-preserve-uid.ics") assert.Contains(t, rec3.Body.String(), "UID:test-preserve-uid") }) } func TestCRUDDelete(t *testing.T) { t.Run("DELETE existing task returns 204", func(t *testing.T) { e := setupTestEnv(t) // Task 40 (uid-caldav-test) exists in project 36 rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusNoContent, rec.Code, "DELETE should return 204 No Content. Body:\n%s", rec.Body.String()) }) t.Run("DELETE task makes it unreachable", func(t *testing.T) { e := setupTestEnv(t) // Delete task 40 rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusNoContent, rec.Code) // Try to GET it — should 404 rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") assert.Equal(t, http.StatusNotFound, rec2.Code, "GET after DELETE should return 404") }) t.Run("DELETE nonexistent task returns 404", func(t *testing.T) { e := setupTestEnv(t) rec := caldavDELETE(t, e, "/dav/projects/36/nonexistent-uid.ics") assert.Equal(t, http.StatusNotFound, rec.Code, "DELETE nonexistent task should return 404") }) t.Run("DELETE task removes it from project listing", func(t *testing.T) { t.Skip("Known bug: DeleteResource relies on GetResource being called first to populate task ID — delete silently fails") e := setupTestEnv(t) // First verify task exists in project listing rec := caldavGET(t, e, "/dav/projects/36") assert.Contains(t, rec.Body.String(), "uid-caldav-test") // Delete it caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") // Verify it's gone from the listing rec2 := caldavGET(t, e, "/dav/projects/36") assert.NotContains(t, rec2.Body.String(), "uid-caldav-test") }) t.Run("Full lifecycle: PUT create -> GET read -> PUT update -> DELETE", func(t *testing.T) { e := setupTestEnv(t) uid := "test-lifecycle-uid" path := "/dav/projects/36/" + uid + ".ics" // Create vtodo := NewVTodo(uid, "Lifecycle Test").Build() rec := caldavPUT(t, e, path, vtodo) assert.Equal(t, http.StatusCreated, rec.Code, "Create failed") // Read rec = caldavGET(t, e, path) assert.Equal(t, http.StatusOK, rec.Code, "Read failed") assert.Contains(t, rec.Body.String(), "Lifecycle Test") // Update vtodo2 := NewVTodo(uid, "Lifecycle Test Updated").Build() rec = caldavPUT(t, e, path, vtodo2) assert.True(t, rec.Code >= 200 && rec.Code < 300, "Update failed with %d", rec.Code) // Verify update rec = caldavGET(t, e, path) assert.Contains(t, rec.Body.String(), "Lifecycle Test Updated") // Delete rec = caldavDELETE(t, e, path) assert.Equal(t, http.StatusNoContent, rec.Code, "Delete failed") // Verify gone rec = caldavGET(t, e, path) assert.Equal(t, http.StatusNotFound, rec.Code, "Task should be gone after delete") }) }