Files
vikunja/pkg/caldavtests/relations_test.go

236 lines
9.3 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 (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRelationsBasic(t *testing.T) {
// RFC 5545 §3.8.4.5 (rfc5545.txt line 6391):
// "This property is used to represent a relationship or reference
// between one calendar component and another."
t.Run("Parent with RELTYPE=CHILD and child with RELTYPE=PARENT", func(t *testing.T) {
e := setupTestEnv(t)
// Create parent (no relations)
parent := NewVTodo("rel-parent-1", "Parent Task").Build()
rec := caldavPUT(t, e, "/dav/projects/36/rel-parent-1.ics", parent)
require.Equal(t, 201, rec.Code)
// Create child referencing parent
child := NewVTodo("rel-child-1", "Child Task").
RelatedToParent("rel-parent-1").
Build()
rec = caldavPUT(t, e, "/dav/projects/36/rel-child-1.ics", child)
require.Equal(t, 201, rec.Code)
// GET child — should have RELATED-TO;RELTYPE=PARENT
rec = caldavGET(t, e, "/dav/projects/36/rel-child-1.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rel-parent-1",
"Child should have RELATED-TO pointing to parent")
// GET parent — should have RELATED-TO;RELTYPE=CHILD (inverse)
rec = caldavGET(t, e, "/dav/projects/36/rel-parent-1.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:rel-child-1",
"Parent should have inverse RELATED-TO pointing to child")
})
t.Run("Grandchild chain: parent -> child -> grandchild", func(t *testing.T) {
e := setupTestEnv(t)
// Create in order: parent, child, grandchild
parent := NewVTodo("rel-gp-parent", "Grandparent").Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-parent.ics", parent)
child := NewVTodo("rel-gp-child", "Parent").
RelatedToParent("rel-gp-parent").
Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-child.ics", child)
grandchild := NewVTodo("rel-gp-grandchild", "Child").
RelatedToParent("rel-gp-child").
Build()
caldavPUT(t, e, "/dav/projects/36/rel-gp-grandchild.ics", grandchild)
// Verify middle node has both parent and child relations
rec := caldavGET(t, e, "/dav/projects/36/rel-gp-child.ics")
body := rec.Body.String()
assert.Contains(t, body, "RELATED-TO;RELTYPE=PARENT:rel-gp-parent")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rel-gp-grandchild")
})
}
func TestRelationsReverseOrder(t *testing.T) {
t.Run("Child arrives before parent (Tasks.org pattern)", func(t *testing.T) {
// This is the most common real-world scenario:
// Tasks.org sends child with RELATED-TO;RELTYPE=PARENT but the parent
// has NO RELATED-TO at all. The child may arrive before the parent.
e := setupTestEnv(t)
// Step 1: Child arrives first
child := NewVTodo("rev-child-first", "Child First").
RelatedToParent("rev-parent-late").
Build()
rec := caldavPUT(t, e, "/dav/projects/36/rev-child-first.ics", child)
require.Equal(t, 201, rec.Code)
// Step 2: Parent arrives later (no RELATED-TO)
parent := NewVTodo("rev-parent-late", "Parent Late").Build()
rec = caldavPUT(t, e, "/dav/projects/36/rev-parent-late.ics", parent)
require.Equal(t, 201, rec.Code)
// Step 3: Verify parent has correct title (not DUMMY-UID)
rec = caldavGET(t, e, "/dav/projects/36/rev-parent-late.ics")
assert.Contains(t, rec.Body.String(), "SUMMARY:Parent Late",
"Parent should have its real title, not DUMMY-UID")
assert.NotContains(t, rec.Body.String(), "DUMMY",
"DUMMY placeholder should be replaced")
// Step 4: Verify child still has parent relation
rec = caldavGET(t, e, "/dav/projects/36/rev-child-first.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rev-parent-late",
"Child should still have parent relation after parent arrives")
})
t.Run("Multiple children before parent", func(t *testing.T) {
e := setupTestEnv(t)
// Two children arrive before parent
child1 := NewVTodo("rev-mc1", "Multi Child 1").
RelatedToParent("rev-mparent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mc1.ics", child1)
child2 := NewVTodo("rev-mc2", "Multi Child 2").
RelatedToParent("rev-mparent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mc2.ics", child2)
// Parent arrives
parent := NewVTodo("rev-mparent", "Multi Parent").Build()
caldavPUT(t, e, "/dav/projects/36/rev-mparent.ics", parent)
// Verify parent shows both children
rec := caldavGET(t, e, "/dav/projects/36/rev-mparent.ics")
body := rec.Body.String()
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc1")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc2")
})
}
func TestRelationsCrossProject(t *testing.T) {
t.Run("Parent in project 36, child in project 38", func(t *testing.T) {
e := setupTestEnv(t)
parent := NewVTodo("xp-parent", "Cross-Project Parent").Build()
rec := caldavPUT(t, e, "/dav/projects/36/xp-parent.ics", parent)
require.Equal(t, 201, rec.Code)
child := NewVTodo("xp-child", "Cross-Project Child").
RelatedToParent("xp-parent").Build()
rec = caldavPUT(t, e, "/dav/projects/38/xp-child.ics", child)
require.Equal(t, 201, rec.Code)
// Verify parent in project 36 knows about child
rec = caldavGET(t, e, "/dav/projects/36/xp-parent.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:xp-child",
"Parent should have cross-project child relation")
// Verify child in project 38 knows about parent
rec = caldavGET(t, e, "/dav/projects/38/xp-child.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:xp-parent",
"Child should have cross-project parent relation")
})
t.Run("Pre-existing cross-project relations from fixtures", func(t *testing.T) {
e := setupTestEnv(t)
// Task 45 (project 36) and task 46 (project 38) have cross-project relations in fixtures
rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task-another-list.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-another-list")
rec = caldavGET(t, e, "/dav/projects/38/uid-caldav-test-child-task-another-list.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-another-list")
})
}
func TestRelationsDeletion(t *testing.T) {
t.Run("Deleting child removes relation from parent", func(t *testing.T) {
e := setupTestEnv(t)
// Task 41 is parent of task 43 (from fixtures)
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
assert.Equal(t, 204, rec.Code)
// Parent should no longer reference deleted child
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task\r\n",
"Parent should not reference deleted child")
})
t.Run("Deleting parent removes relation from child", func(t *testing.T) {
e := setupTestEnv(t)
// Delete parent task 41
rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics")
assert.Equal(t, 204, rec.Code)
// Child should no longer reference deleted parent
rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics")
assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task",
"Child should not reference deleted parent")
})
}
func TestRelationsResync(t *testing.T) {
t.Run("Parent re-sync without RELATED-TO preserves child relations", func(t *testing.T) {
// This is the DAVx5 behavior: parent is updated (e.g., title change)
// and re-synced without any RELATED-TO. The child-declared relations
// should survive.
e := setupTestEnv(t)
// Create parent
parent := NewVTodo("resync-parent", "Original Parent").Build()
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parent)
// Create child with parent relation
child := NewVTodo("resync-child", "Child").
RelatedToParent("resync-parent").Build()
caldavPUT(t, e, "/dav/projects/36/resync-child.ics", child)
// Re-sync parent with updated title but NO RELATED-TO
parentUpdated := NewVTodo("resync-parent", "Updated Parent Title").Build()
caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parentUpdated)
// Verify relations survived
rec := caldavGET(t, e, "/dav/projects/36/resync-parent.ics")
body := rec.Body.String()
assert.Contains(t, body, "Updated Parent Title", "Title should be updated")
assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:resync-child",
"Child relation should survive parent re-sync without RELATED-TO")
rec = caldavGET(t, e, "/dav/projects/36/resync-child.ics")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:resync-parent",
"Parent relation on child should survive parent re-sync")
})
}