359 lines
12 KiB
Go
359 lines
12 KiB
Go
// 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 <https://www.gnu.org/licenses/>.
|
|
|
|
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")
|
|
})
|
|
}
|