diff --git a/pkg/caldavtests/relations_test.go b/pkg/caldavtests/relations_test.go new file mode 100644 index 000000000..0d09ee898 --- /dev/null +++ b/pkg/caldavtests/relations_test.go @@ -0,0 +1,235 @@ +// 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" + + "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") + }) +}