From 225d65268d187f72b5aecdfb84b548482b6e6b02 Mon Sep 17 00:00:00 2001 From: Miguel Arroyo Date: Fri, 10 Nov 2023 22:44:03 +0000 Subject: [PATCH] feat(caldav): Add support for subtasks (i.e. `RELATED-TO` property) in CalDAV (#1634) As I mentioned [here](https://kolaente.dev/vikunja/api/pulls/1442#issuecomment-55215), this is mainly a cleanup of @zewaren 's original [PR](https://kolaente.dev/vikunja/api/pulls/1442). It adds support for the `RELATED-TO` property in CalDAV's `VTODO` and the `RELTYPE=PARENT` and `RELTYPE=CHILD` relationships. In other words, it allows for `ParentTask->SubTask` relations to be handled supported through CalDAV. In addition to the included tests, this has been tested by both @zewaren & myself with DAVx5 & Tasks (Android) and it's been working great. Resolves https://kolaente.dev/vikunja/api/issues/1345 Co-authored-by: Miguel A. Arroyo Co-authored-by: Erwan Martin Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1634 Reviewed-by: konrad Co-authored-by: Miguel Arroyo Co-committed-by: Miguel Arroyo --- pkg/caldav/caldav.go | 82 ++- pkg/caldav/caldav_test.go | 43 ++ pkg/caldav/parsing.go | 48 +- pkg/caldav/parsing_test.go | 182 +++++++ pkg/db/fixtures/buckets.yml | 6 + pkg/db/fixtures/projects.yml | 9 + pkg/db/fixtures/task_relations.yml | 36 ++ pkg/db/fixtures/tasks.yml | 86 +++- pkg/db/fixtures/users_projects.yml | 12 + pkg/integrations/caldav_test.go | 271 +++++++++- pkg/integrations/integrations.go | 52 +- pkg/models/kanban_test.go | 2 +- pkg/models/tasks.go | 9 +- pkg/routes/caldav/listStorageProvider.go | 103 ++++ pkg/routes/caldav/listStorageProvider_test.go | 484 ++++++++++++++++++ 15 files changed, 1351 insertions(+), 74 deletions(-) create mode 100644 pkg/routes/caldav/listStorageProvider_test.go diff --git a/pkg/caldav/caldav.go b/pkg/caldav/caldav.go index f818caa36c4..04a8a644558 100644 --- a/pkg/caldav/caldav.go +++ b/pkg/caldav/caldav.go @@ -38,21 +38,21 @@ type Todo struct { UID string // Optional - Summary string - Description string - Completed time.Time - Organizer *user.User - Priority int64 // 0-9, 1 is highest - RelatedToUID string - Color string - Categories []string - Start time.Time - End time.Time - DueDate time.Time - Duration time.Duration - RepeatAfter int64 - RepeatMode models.TaskRepeatMode - Alarms []Alarm + Summary string + Description string + Completed time.Time + Organizer *user.User + Priority int64 // 0-9, 1 is highest + Relations []Relation + Color string + Categories []string + Start time.Time + End time.Time + DueDate time.Time + Duration time.Duration + RepeatAfter int64 + RepeatMode models.TaskRepeatMode + Alarms []Alarm Created time.Time Updated time.Time // last-mod @@ -66,6 +66,11 @@ type Alarm struct { Description string } +type Relation struct { + Type models.RelationKind + UID string +} + // Config is the caldav calendar config type Config struct { Name string @@ -147,11 +152,6 @@ STATUS:COMPLETED` ORGANIZER;CN=:` + t.Organizer.Username } - if t.RelatedToUID != "" { - caldavtodos += ` -RELATED-TO:` + t.RelatedToUID - } - if t.DueDate.Unix() > 0 { caldavtodos += ` DUE:` + makeCalDavTimeFromTimeStamp(t.DueDate) @@ -185,6 +185,7 @@ CATEGORIES:` + strings.Join(t.Categories, ",") caldavtodos += ` LAST-MODIFIED:` + makeCalDavTimeFromTimeStamp(t.Updated) caldavtodos += ParseAlarms(t.Alarms, t.Summary) + caldavtodos += ParseRelations(t.Relations) caldavtodos += ` END:VTODO` } @@ -222,6 +223,47 @@ END:VALARM` return caldavalarms } +func ParseRelations(relations []Relation) (caldavrelatedtos string) { + + for _, r := range relations { + switch r.Type { + case models.RelationKindParenttask: + caldavrelatedtos += ` +RELATED-TO;RELTYPE=PARENT:` + case models.RelationKindSubtask: + caldavrelatedtos += ` +RELATED-TO;RELTYPE=CHILD:` + case models.RelationKindUnknown: + continue + case models.RelationKindRelated: + continue + case models.RelationKindDuplicateOf: + continue + case models.RelationKindDuplicates: + continue + case models.RelationKindBlocking: + continue + case models.RelationKindBlocked: + continue + case models.RelationKindPreceeds: + continue + case models.RelationKindFollows: + continue + case models.RelationKindCopiedFrom: + continue + case models.RelationKindCopiedTo: + continue + default: + caldavrelatedtos += ` +RELATED-TO:` + } + + caldavrelatedtos += r.UID + } + + return caldavrelatedtos +} + func makeCalDavTimeFromTimeStamp(ts time.Time) (caldavtime string) { return ts.In(time.UTC).Format(DateFormat) + "Z" } diff --git a/pkg/caldav/caldav_test.go b/pkg/caldav/caldav_test.go index cd046271415..dc820e6e531 100644 --- a/pkg/caldav/caldav_test.go +++ b/pkg/caldav/caldav_test.go @@ -326,6 +326,49 @@ ACTION:DISPLAY DESCRIPTION:Todo #1 END:VALARM END:VTODO +END:VCALENDAR`, + }, + { + name: "with related-to", + args: args{ + config: &Config{ + Name: "test", + ProdID: "RandomProdID which is not random", + }, + todos: []*Todo{ + { + Summary: "Todo #1", + Description: "Lorem Ipsum", + UID: "randommduid", + Relations: []Relation{ + { + Type: models.RelationKindParenttask, + UID: "parentuid", + }, + { + Type: models.RelationKindSubtask, + UID: "subtaskuid", + }, + }, + Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, + }, + wantCaldavtasks: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randommduid +DTSTAMP:20181201T011204Z +SUMMARY:Todo #1 +DESCRIPTION:Lorem Ipsum +LAST-MODIFIED:00010101T000000Z +RELATED-TO;RELTYPE=PARENT:parentuid +RELATED-TO;RELTYPE=CHILD:subtaskuid +END:VTODO END:VCALENDAR`, }, } diff --git a/pkg/caldav/parsing.go b/pkg/caldav/parsing.go index 5994c008f88..dea312d5d65 100644 --- a/pkg/caldav/parsing.go +++ b/pkg/caldav/parsing.go @@ -23,7 +23,6 @@ import ( "time" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/utils" @@ -51,6 +50,16 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT }) } + var relations []Relation + for reltype, tasks := range t.RelatedTasks { + for _, r := range tasks { + relations = append(relations, Relation{ + Type: reltype, + UID: r.UID, + }) + } + } + caldavtodos = append(caldavtodos, &Todo{ Timestamp: t.Updated, UID: t.UID, @@ -69,6 +78,7 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT RepeatAfter: t.RepeatAfter, RepeatMode: t.RepeatMode, Alarms: alarms, + Relations: relations, }) } @@ -91,11 +101,11 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { } // We put the vTodo details in a map to be able to handle them more easily task := make(map[string]ics.IANAProperty) - var relation ics.IANAProperty + var relations []ics.IANAProperty for _, c := range vTodo.UnknownPropertiesIANAProperties() { task[c.IANAToken] = c if strings.HasPrefix(c.IANAToken, "RELATED-TO") { - relation = c + relations = append(relations, c) } } @@ -139,17 +149,33 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { DoneAt: caldavTimeToTimestamp(task["COMPLETED"]), } - if relation.Value != "" { - s := db.NewSession() - defer s.Close() + for _, c := range relations { + var relTypeStr string + if _, ok := c.ICalParameters["RELTYPE"]; ok { + if len(c.ICalParameters["RELTYPE"]) != 1 { + continue + } - subtask, err := models.GetTaskSimpleByUUID(s, relation.Value) - if err != nil { - return nil, err + relTypeStr = c.ICalParameters["RELTYPE"][0] } - vTask.RelatedTasks = make(map[models.RelationKind][]*models.Task) - vTask.RelatedTasks[models.RelationKindSubtask] = []*models.Task{subtask} + var relationKind models.RelationKind + switch relTypeStr { + case "PARENT": + relationKind = models.RelationKindParenttask + case "CHILD": + relationKind = models.RelationKindSubtask + default: + relationKind = models.RelationKindParenttask + } + + if vTask.RelatedTasks == nil { + vTask.RelatedTasks = make(map[models.RelationKind][]*models.Task) + } + + vTask.RelatedTasks[relationKind] = append(vTask.RelatedTasks[relationKind], &models.Task{ + UID: c.Value, + }) } if task["STATUS"].Value == "COMPLETED" { diff --git a/pkg/caldav/parsing_test.go b/pkg/caldav/parsing_test.go index 544e555ab9d..695895c4f92 100644 --- a/pkg/caldav/parsing_test.go +++ b/pkg/caldav/parsing_test.go @@ -219,6 +219,70 @@ END:VCALENDAR`, Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), }, }, + { + name: "With parent", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:SubTask #1 +DESCRIPTION:Lorem Ipsum +LAST-MODIFIED:00010101T000000 +RELATED-TO;RELTYPE=PARENT:randomuid_parent +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "SubTask #1", + UID: "randomuid", + Description: "Lorem Ipsum", + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + UID: "randomuid_parent", + }, + }, + }, + }, + }, + { + name: "With subtask", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:Parent +DESCRIPTION:Lorem Ipsum +LAST-MODIFIED:00010101T000000 +RELATED-TO;RELTYPE=CHILD:randomuid_child +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "Parent", + UID: "randomuid", + Description: "Lorem Ipsum", + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindSubtask: { + { + UID: "randomuid_child", + }, + }, + }, + }, + }, { name: "example task from tasks.org app", args: args{content: `BEGIN:VCALENDAR @@ -392,6 +456,124 @@ ACTION:DISPLAY DESCRIPTION:Task 1 END:VALARM END:VTODO +END:VCALENDAR`, + }, + { + name: "Format Task with Related Tasks as CalDAV", + args: args{ + list: &models.ProjectWithTasksAndBuckets{ + Project: models.Project{ + Title: "List title", + }, + }, + tasks: []*models.TaskWithComments{ + { + Task: models.Task{ + Title: "Parent Task", + UID: "randomuid_parent", + Description: "A parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindSubtask: { + { + Title: "Subtask 1", + UID: "randomuid_child_1", + Description: "The first child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + { + Title: "Subtask 2", + UID: "randomuid_child_2", + Description: "The second child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + { + Task: models.Task{ + Title: "Subtask 1", + UID: "randomuid_child_1", + Description: "The first child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + Title: "Parent task", + UID: "randomuid_parent", + Description: "A parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + { + Task: models.Task{ + Title: "Subtask 2", + UID: "randomuid_child_2", + Description: "The second child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + Title: "Parent task", + UID: "randomuid_parent", + Description: "A parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + }, + }, + wantCaldav: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:List title +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:randomuid_parent +DTSTAMP:20181201T011205Z +SUMMARY:Parent Task +DESCRIPTION:A parent task +CREATED:20181201T011201Z +PRIORITY:3 +LAST-MODIFIED:20181201T011205Z +RELATED-TO;RELTYPE=CHILD:randomuid_child_1 +RELATED-TO;RELTYPE=CHILD:randomuid_child_2 +END:VTODO +BEGIN:VTODO +UID:randomuid_child_1 +DTSTAMP:20181201T011204Z +SUMMARY:Subtask 1 +DESCRIPTION:The first child task +CREATED:20181201T011204Z +LAST-MODIFIED:20181201T011204Z +RELATED-TO;RELTYPE=PARENT:randomuid_parent +END:VTODO +BEGIN:VTODO +UID:randomuid_child_2 +DTSTAMP:20181201T011204Z +SUMMARY:Subtask 2 +DESCRIPTION:The second child task +CREATED:20181201T011204Z +LAST-MODIFIED:20181201T011204Z +RELATED-TO;RELTYPE=PARENT:randomuid_parent +END:VTODO END:VCALENDAR`, }, } diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index f5ca83bdd7b..4d7fa3f12bf 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -236,3 +236,9 @@ created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 +- id: 39 + title: testbucket38 + project_id: 38 + created_by_id: 15 + created: 2020-04-18 21:13:52 + updated: 2020-04-18 21:13:52 \ No newline at end of file diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index 8dee3cae1f5..9d99094b01e 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -327,3 +327,12 @@ position: 1 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- + id: 38 + title: Project 38 for Caldav tests + description: Lorem Ipsum + identifier: test38 + owner_id: 15 + position: 2 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 \ No newline at end of file diff --git a/pkg/db/fixtures/task_relations.yml b/pkg/db/fixtures/task_relations.yml index 750f2e217c1..9601254b3cb 100644 --- a/pkg/db/fixtures/task_relations.yml +++ b/pkg/db/fixtures/task_relations.yml @@ -34,3 +34,39 @@ relation_kind: 'related' created_by_id: 1 created: 2018-12-01 15:13:12 +- id: 7 + task_id: 41 + other_task_id: 43 + relation_kind: 'subtask' + created_by_id: 15 + created: 2018-12-01 15:13:12 +- id: 8 + task_id: 43 + other_task_id: 41 + relation_kind: 'parenttask' + created_by_id: 15 + created: 2018-12-01 15:13:12 +- id: 9 + task_id: 41 + other_task_id: 44 + relation_kind: 'subtask' + created_by_id: 15 + created: 2018-12-01 15:13:12 +- id: 10 + task_id: 44 + other_task_id: 41 + relation_kind: 'parenttask' + created_by_id: 15 + created: 2018-12-01 15:13:12 +- id: 11 + task_id: 45 + other_task_id: 46 + relation_kind: 'subtask' + created_by_id: 15 + created: 2018-12-01 15:13:12 +- id: 12 + task_id: 46 + other_task_id: 45 + relation_kind: 'parenttask' + created_by_id: 15 + created: 2018-12-01 15:13:12 \ No newline at end of file diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index 77e11ae4d08..fe636fb1028 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -374,5 +374,89 @@ due_date: 2023-03-01 15:00:00 created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 - bucket_id: 1 + bucket_id: 38 position: 39 +- id: 41 + uid: 'uid-caldav-test-parent-task' + title: 'Parent task for Caldav Test' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + project_id: 36 + index: 40 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 40 +- id: 42 + uid: 'uid-caldav-test-parent-task-2' + title: 'Parent task for Caldav Test 2' + description: 'Description Caldav Test 2' + priority: 3 + done: false + created_by_id: 15 + project_id: 36 + index: 41 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 41 +- id: 43 + uid: 'uid-caldav-test-child-task' + title: 'Child task for Caldav Test' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + project_id: 36 + index: 42 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 42 +- id: 44 + uid: 'uid-caldav-test-child-task-2' + title: 'Child task for Caldav Test ' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + project_id: 38 + index: 43 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 43 +- id: 45 + uid: 'uid-caldav-test-parent-task-another-list' + title: 'Parent task for Caldav Test' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + project_id: 36 + index: 44 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 44 +- id: 46 + uid: 'uid-caldav-test-child-task-another-list' + title: 'Child task for Caldav Test ' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + project_id: 38 + index: 45 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 38 + position: 45 \ No newline at end of file diff --git a/pkg/db/fixtures/users_projects.yml b/pkg/db/fixtures/users_projects.yml index 4fb583c464f..0d5ebf3f0e6 100644 --- a/pkg/db/fixtures/users_projects.yml +++ b/pkg/db/fixtures/users_projects.yml @@ -100,3 +100,15 @@ right: 0 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 18 + user_id: 15 + project_id: 36 + right: 0 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 +- id: 19 + user_id: 15 + project_id: 38 + right: 0 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 \ No newline at end of file diff --git a/pkg/integrations/caldav_test.go b/pkg/integrations/caldav_test.go index cb364de6f9f..a1cc8e68ec9 100644 --- a/pkg/integrations/caldav_test.go +++ b/pkg/integrations/caldav_test.go @@ -24,7 +24,20 @@ import ( "github.com/stretchr/testify/assert" ) -const vtodo = `BEGIN:VCALENDAR +func TestCaldav(t *testing.T) { + t.Run("Delivers VTODO for project", func(t *testing.T) { + e, _ := setupTestEnv() + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "36"}) + assert.NoError(t, err) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "PRODID:-//Vikunja Todo App//EN") + assert.Contains(t, rec.Body.String(), "X-WR-CALNAME:Project 36 for Caldav tests") + assert.Contains(t, rec.Body.String(), "BEGIN:VTODO") + assert.Contains(t, rec.Body.String(), "END:VTODO") + assert.Contains(t, rec.Body.String(), "END:VCALENDAR") + }) + t.Run("Import VTODO", func(t *testing.T) { + const vtodo = `BEGIN:VCALENDAR VERSION:2.0 METHOD:PUBLISH X-PUBLISHED-TTL:PT4H @@ -44,24 +57,14 @@ END:VALARM END:VTODO END:VCALENDAR` -func TestCaldav(t *testing.T) { - t.Run("Delivers VTODO for project", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "36"}) - assert.NoError(t, err) - assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") - assert.Contains(t, rec.Body.String(), "PRODID:-//Vikunja Todo App//EN") - assert.Contains(t, rec.Body.String(), "X-WR-CALNAME:Project 36 for Caldav tests") - assert.Contains(t, rec.Body.String(), "BEGIN:VTODO") - assert.Contains(t, rec.Body.String(), "END:VTODO") - assert.Contains(t, rec.Body.String(), "END:VCALENDAR") - }) - t.Run("Import VTODO", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "36", "task": "uid"}) + e, _ := setupTestEnv() + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "36", "task": "uid"}) assert.NoError(t, err) assert.Equal(t, 201, rec.Result().StatusCode) }) t.Run("Export VTODO", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test"}) + e, _ := setupTestEnv() + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test"}) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") assert.Contains(t, rec.Body.String(), "SUMMARY:Title Caldav Test") @@ -75,3 +78,241 @@ func TestCaldav(t *testing.T) { assert.Contains(t, rec.Body.String(), "END:VALARM") }) } + +func TestCaldavSubtasks(t *testing.T) { + const vtodoHeader = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +` + const vtodoFooter = ` +END:VCALENDAR` + + t.Run("Import Task & Subtask", func(t *testing.T) { + + const vtodoParentTaskStub = `BEGIN:VTODO +UID:uid_parent_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO` + + const vtodoChildTaskStub = `BEGIN:VTODO +UID:uid_child_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_parent_import +END:VTODO` + + const vtodoGrandChildTaskStub = ` +BEGIN:VTODO +UID:uid_grand_child_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child_import +END:VTODO` + + e, _ := setupTestEnv() + + const parentVTODO = vtodoHeader + vtodoParentTaskStub + vtodoFooter + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, parentVTODO, nil, map[string]string{"project": "36", "task": "uid_parent_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + const childVTODO = vtodoHeader + vtodoChildTaskStub + vtodoFooter + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, childVTODO, nil, map[string]string{"project": "36", "task": "uid_child_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + const grandChildVTODO = vtodoHeader + vtodoGrandChildTaskStub + vtodoFooter + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, grandChildVTODO, nil, map[string]string{"project": "36", "task": "uid_grand_child_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "36"}) + assert.NoError(t, err) + assert.Equal(t, 200, rec.Result().StatusCode) + + assert.Contains(t, rec.Body.String(), "UID:uid_parent_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid_child_import") + assert.Contains(t, rec.Body.String(), "UID:uid_child_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid_grand_child_import") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child_import") + }) + + t.Run("Import Task & Subtask (Reverse - Subtask first)", func(t *testing.T) { + e, _ := setupTestEnv() + + const vtodoGrandChildTaskStub = ` +BEGIN:VTODO +UID:uid_grand_child_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child_import +END:VTODO` + + const grandChildVTODO = vtodoHeader + vtodoGrandChildTaskStub + vtodoFooter + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, grandChildVTODO, nil, map[string]string{"project": "36", "task": "uid_grand_child_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + const vtodoChildTaskStub = `BEGIN:VTODO +UID:uid_child_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_parent_import +RELATED-TO;RELTYPE=CHILD:uid_grand_child_import +END:VTODO` + + const childVTODO = vtodoHeader + vtodoChildTaskStub + vtodoFooter + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, childVTODO, nil, map[string]string{"project": "36", "task": "uid_child_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + const vtodoParentTaskStub = `BEGIN:VTODO +UID:uid_parent_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=CHILD:uid_child_import +END:VTODO` + + const parentVTODO = vtodoHeader + vtodoParentTaskStub + vtodoFooter + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, parentVTODO, nil, map[string]string{"project": "36", "task": "uid_parent_import"}) + assert.NoError(t, err) + assert.Equal(t, 201, rec.Result().StatusCode) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "36"}) + assert.NoError(t, err) + assert.Equal(t, 200, rec.Result().StatusCode) + + assert.Contains(t, rec.Body.String(), "UID:uid_parent_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid_child_import") + assert.Contains(t, rec.Body.String(), "UID:uid_child_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid_grand_child_import") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child_import") + }) + + t.Run("Delete Subtask", func(t *testing.T) { + e, _ := setupTestEnv() + + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodDelete, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-child-task"}) + assert.NoError(t, err) + assert.Equal(t, 204, rec.Result().StatusCode) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodDelete, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-child-task-2"}) + assert.NoError(t, err) + assert.Equal(t, 204, rec.Result().StatusCode) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-parent-task"}) + assert.NoError(t, err) + assert.Equal(t, 200, rec.Result().StatusCode) + + assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task") + assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-2") + }) + + t.Run("Delete Parent Task", func(t *testing.T) { + e, _ := setupTestEnv() + + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodDelete, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-parent-task"}) + assert.NoError(t, err) + assert.Equal(t, 204, rec.Result().StatusCode) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-child-task"}) + assert.NoError(t, err) + assert.Equal(t, 200, rec.Result().StatusCode) + + assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task") + }) + +} + +func TestCaldavSubtasksDifferentLists(t *testing.T) { + t.Run("Import Parent Task & Child Task Different Lists", func(t *testing.T) { + const vtodoParentTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_parent_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + + const vtodoChildTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 38 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child_import +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_parent_import +END:VTODO +END:VCALENDAR` + + e, _ := setupTestEnv() + + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "36", "task": "uid_parent_import"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask, nil, map[string]string{"project": "38", "task": "uid_child_import"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid_parent_import"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "UID:uid_parent_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid_child_import") + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "38", "task": "uid_child_import"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "UID:uid_child_import") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_import") + }) + + t.Run("Check relationships across lists", func(t *testing.T) { + e, _ := setupTestEnv() + + rec, err := newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "task": "uid-caldav-test-parent-task-another-list"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "UID:uid-caldav-test-parent-task-another-list") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-another-list") + + rec, err = newCaldavTestRequestWithUser(t, e, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "38", "task": "uid-caldav-test-child-task-another-list"}) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "UID:uid-caldav-test-child-task-another-list") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-another-list") + }) +} diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index bf6ce45eb92..205184bd0b5 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -79,23 +79,36 @@ func setupTestEnv() (e *echo.Echo, err error) { return } -func bootstrapTestRequest(t *testing.T, method string, payload string, queryParam url.Values) (c echo.Context, rec *httptest.ResponseRecorder) { - // Setup - e, err := setupTestEnv() - assert.NoError(t, err) - - // Do the actual request +func createRequest(e *echo.Echo, method string, payload string, queryParam url.Values, urlParams map[string]string) (c echo.Context, rec *httptest.ResponseRecorder) { req := httptest.NewRequest(method, "/", strings.NewReader(payload)) req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) req.URL.RawQuery = queryParam.Encode() rec = httptest.NewRecorder() c = e.NewContext(req, rec) + var paramNames []string + var paramValues []string + for name, value := range urlParams { + paramNames = append(paramNames, name) + paramValues = append(paramValues, value) + } + c.SetParamNames(paramNames...) + c.SetParamValues(paramValues...) + return +} + +func bootstrapTestRequest(t *testing.T, method string, payload string, queryParam url.Values, urlParams map[string]string) (c echo.Context, rec *httptest.ResponseRecorder) { + // Setup + e, err := setupTestEnv() + assert.NoError(t, err) + + c, rec = createRequest(e, method, payload, queryParam, urlParams) return } func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context) error, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + var c echo.Context + c, rec = bootstrapTestRequest(t, method, payload, queryParams, urlParams) err = handler(c) return } @@ -124,36 +137,25 @@ func addLinkShareTokenToContext(t *testing.T, share *models.LinkSharing, c echo. c.Set("user", tken) } -func testRequestSetup(t *testing.T, method string, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, c echo.Context) { - c, rec = bootstrapTestRequest(t, method, payload, queryParams) - - var paramNames []string - var paramValues []string - for name, value := range urlParams { - paramNames = append(paramNames, name) - paramValues = append(paramValues, value) - } - c.SetParamNames(paramNames...) - c.SetParamValues(paramValues...) - return -} - func newTestRequestWithUser(t *testing.T, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + var c echo.Context + c, rec = bootstrapTestRequest(t, method, payload, queryParams, urlParams) addUserTokenToContext(t, user, c) err = handler(c) return } func newTestRequestWithLinkShare(t *testing.T, method string, handler echo.HandlerFunc, share *models.LinkSharing, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + var c echo.Context + c, rec = bootstrapTestRequest(t, method, payload, queryParams, urlParams) addLinkShareTokenToContext(t, share, c) err = handler(c) return } -func newCaldavTestRequestWithUser(t *testing.T, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) +func newCaldavTestRequestWithUser(t *testing.T, e *echo.Echo, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { + var c echo.Context + c, rec = createRequest(e, method, payload, queryParams, urlParams) c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextPlain) result, _ := caldav.BasicAuth(user.Username, "1234", c) diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index 93eebce0588..181d67ca1f3 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -147,7 +147,7 @@ func TestBucket_Delete(t *testing.T) { tasks := []*Task{} err = s.Where("bucket_id = ?", 1).Find(&tasks) assert.NoError(t, err) - assert.Len(t, tasks, 16) + assert.Len(t, tasks, 15) db.AssertMissing(t, "buckets", map[string]interface{}{ "id": 2, "project_id": 1, diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 151b67de801..b128a09e27d 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -373,7 +373,14 @@ func (bt *BulkTask) GetTasksByIDs(s *xorm.Session) (err error) { } func GetTaskSimpleByUUID(s *xorm.Session, uid string) (task *Task, err error) { - _, err = s.In("uid", uid).Get(task) + var has bool + task = &Task{} + + has, err = s.In("uid", uid).Get(task) + if !has || err != nil { + return &Task{}, ErrTaskDoesNotExist{} + } + return } diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 0ef900460ff..20f9a2e047e 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -17,6 +17,7 @@ package caldav import ( + "slices" "strconv" "strings" "time" @@ -292,6 +293,13 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) ( return nil, err } + vcls.task.ProjectID = vcls.project.ID + err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks) + if err != nil { + _ = s.Rollback() + return nil, err + } + if err := s.Commit(); err != nil { return nil, err } @@ -316,6 +324,10 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( // At this point, we already have the right task in vcls.task, so we can use that ID directly vTask.ID = vcls.task.ID + // Explicitly set the ProjectID in case the task now belongs to a different project: + vTask.ProjectID = vcls.project.ID + vcls.task.ProjectID = vcls.project.ID + s := db.NewSession() defer s.Close() @@ -343,6 +355,12 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) ( return nil, err } + err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks) + if err != nil { + _ = s.Rollback() + return nil, err + } + if err := s.Commit(); err != nil { return nil, err } @@ -430,6 +448,91 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod return task.UpdateTaskLabels(s, a, labels) } +func removeStaleRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) { + + // Get the existing task with details: + existingTask := &models.Task{ID: task.ID} + // FIXME: Optimize to get only required attributes (ie. RelatedTasks). + err = existingTask.ReadOne(s, a) + if err != nil { + return + } + + for relationKind, relatedTasks := range existingTask.RelatedTasks { + + for _, relatedTask := range relatedTasks { + relationInNewList := slices.ContainsFunc(newRelations[relationKind], func(newRelation *models.Task) bool { return newRelation.UID == relatedTask.UID }) + + if !relationInNewList { + rel := models.TaskRelation{ + TaskID: task.ID, + OtherTaskID: relatedTask.ID, + RelationKind: relationKind, + } + err = rel.Delete(s, a) + if err != nil { + return + } + } + } + } + + return +} + +// Persist new relations provided by the VTODO entry: +func persistRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) { + + err = removeStaleRelations(s, a, task, newRelations) + if err != nil { + return err + } + + // Ensure the current relations exist: + for relationType, relatedTasksInVTODO := range newRelations { + // Persist each relation independently: + for _, relatedTaskInVTODO := range relatedTasksInVTODO { + + var relatedTask *models.Task + createDummy := false + + // Get the task from the DB: + relatedTaskInDB, err := models.GetTaskSimpleByUUID(s, relatedTaskInVTODO.UID) + if err != nil { + relatedTask = relatedTaskInVTODO + createDummy = true + } else { + relatedTask = relatedTaskInDB + } + + // If the related task doesn't exist, create a dummy one now in the same list. + // It'll probably be populated right after in a following request. + // In the worst case, this was an error by the client and we are left with + // this dummy task to clean up. + if createDummy { + relatedTask.ProjectID = task.ProjectID + relatedTask.Title = "DUMMY-UID-" + relatedTask.UID + err = relatedTask.Create(s, a) + if err != nil { + return err + } + } + + // Create the relation: + rel := models.TaskRelation{ + TaskID: task.ID, + OtherTaskID: relatedTask.ID, + RelationKind: relationType, + } + err = rel.Create(s, a) + if err != nil && !models.IsErrRelationAlreadyExists(err) { + return err + } + } + } + return err +} + // VikunjaProjectResourceAdapter holds the actual resource type VikunjaProjectResourceAdapter struct { project *models.ProjectWithTasksAndBuckets diff --git a/pkg/routes/caldav/listStorageProvider_test.go b/pkg/routes/caldav/listStorageProvider_test.go new file mode 100644 index 00000000000..06cfba5ce00 --- /dev/null +++ b/pkg/routes/caldav/listStorageProvider_test.go @@ -0,0 +1,484 @@ +// 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 Licensee 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 Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package caldav + +// This file tests logic related to handling tasks in CALDAV format + +import ( + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/user" + + "github.com/samedi/caldav-go/data" + "github.com/stretchr/testify/assert" +) + +// Check logic related to creating sub-tasks +func TestSubTask_Create(t *testing.T) { + u := &user.User{ + ID: 15, + Username: "user15", + Email: "user15@example.com", + } + + config.InitDefaultConfig() + files.InitTests() + user.InitTests() + models.SetupTests() + + // + // Create a subtask + // + t.Run("create", func(t *testing.T) { + + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + const taskUID = "uid_child1" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child1 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task 1 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task +END:VTODO +END:VCALENDAR` + + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: &models.Task{UID: taskUID}, + user: u, + } + + // Create the subtask: + taskResource, err := storage.CreateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV contains the relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task") + + // Get the task from the DB: + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + + // Check that the parent-child relationship is present: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask := task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID) + }) + + // + // Create a subtask on a subtask, i.e. create a grand-child + // + t.Run("create grandchild on child task", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + const taskUIDChild = "uid_child1" + const taskContentChild = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child1 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task 1 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task +END:VTODO +END:VCALENDAR` + + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: &models.Task{UID: taskUIDChild}, + user: u, + } + + // Create the subtask: + _, err := storage.CreateResource(taskUIDChild, taskContentChild) + assert.NoError(t, err) + + const taskUID = "uid_grand_child1" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_grand_child1 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task 1 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child1 +END:VTODO +END:VCALENDAR` + + storage = &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: &models.Task{UID: taskUID}, + user: u, + } + + // Create the task: + var taskResource *data.Resource + taskResource, err = storage.CreateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV contains the relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid_child1") + + // Get the task from the DB: + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + + // Check that the parent-child relationship of the grandchildren is present: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask := task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid_child1", parentTask.UID) + + // Get the child task and check that it now has a parent and a child: + tasks, err = models.GetTasksByUIDs(s, []string{"uid_child1"}, u) + assert.NoError(t, err) + task = tasks[0] + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask = task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID) + assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1) + childTask := task.RelatedTasks[models.RelationKindSubtask][0] + assert.Equal(t, taskUID, childTask.UID) + }) + + // + // Create a subtask on a parent that we don't know anything about (yet) + // + t.Run("create subtask on unknown parent", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Create a subtask: + const taskUID = "uid_child1" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child1 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task 1 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet +END:VTODO +END:VCALENDAR` + + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: &models.Task{UID: taskUID}, + user: u, + } + + // Create the task: + taskResource, err := storage.CreateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV contains the relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet") + + // Get the task from the DB: + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + + // Check that the parent-child relationship is present: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask := task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", parentTask.UID) + + // Check that the non-existent parent task was created in the process: + tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-doesnt-exist-yet"}, u) + assert.NoError(t, err) + task = tasks[0] + assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", task.UID) + }) +} + +// Logic related to editing tasks and subtasks +func TestSubTask_Update(t *testing.T) { + u := &user.User{ + ID: 15, + Username: "user15", + Email: "user15@example.com", + } + + // + // Edit a subtask and check that the relations are not gone + // + t.Run("edit subtask", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Edit the subtask: + const taskUID = "uid-caldav-test-child-task" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid-caldav-test-child-task +DTSTAMP:20230301T073337Z +SUMMARY:Child task for Caldav Test (edited) +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task +END:VTODO +END:VCALENDAR` + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: task, + user: u, + } + + // Edit the task: + taskResource, err := storage.UpdateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV still contains the relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task") + + // Get the task from the DB: + tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task = tasks[0] + + // Check that the parent-child relationship is still present: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask := task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID) + }) + + // + // Edit a parent task and check that the subtasks are still linked + // + t.Run("edit parent", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Edit the parent task: + const taskUID = "uid-caldav-test-parent-task" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid-caldav-test-parent-task +DTSTAMP:20230301T073337Z +SUMMARY:Parent task for Caldav Test (edited) +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task +RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-2 +END:VTODO +END:VCALENDAR` + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: task, + user: u, + } + + // Edit the task: + _, err = storage.UpdateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Get the task from the DB: + tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task = tasks[0] + + // Check that the subtasks are still linked: + assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 2) + existingSubTask := task.RelatedTasks[models.RelationKindSubtask][0] + assert.Equal(t, "uid-caldav-test-child-task", existingSubTask.UID) + existingSubTask = task.RelatedTasks[models.RelationKindSubtask][1] + assert.Equal(t, "uid-caldav-test-child-task-2", existingSubTask.UID) + }) + + // + // Edit a subtask and change its parent + // + t.Run("edit subtask change parent", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Edit the subtask: + const taskUID = "uid-caldav-test-child-task" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid-caldav-test-child-task +DTSTAMP:20230301T073337Z +SUMMARY:Child task for Caldav Test (edited) +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2 +END:VTODO +END:VCALENDAR` + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: task, + user: u, + } + + // Edit the task: + taskResource, err := storage.UpdateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV contains the new relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2") + + // Get the task from the DB: + tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task = tasks[0] + + // Check that the parent-child relationship has changed to the new parent: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1) + parentTask := task.RelatedTasks[models.RelationKindParenttask][0] + assert.Equal(t, "uid-caldav-test-parent-task-2", parentTask.UID) + + // Get the previous parent from the DB and check that its previous child is gone: + tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u) + assert.NoError(t, err) + task = tasks[0] + assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1) + // We're gone, but our former sibling is still there: + formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0] + assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID) + }) + + // + // Edit a subtask and remove its parent + // + t.Run("edit subtask remove parent", func(t *testing.T) { + db.LoadAndAssertFixtures(t) + s := db.NewSession() + defer s.Close() + + // Edit the subtask: + const taskUID = "uid-caldav-test-child-task" + const taskContent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 36 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid-caldav-test-child-task +DTSTAMP:20230301T073337Z +SUMMARY:Child task for Caldav Test (edited) +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task := tasks[0] + storage := &VikunjaCaldavProjectStorage{ + project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}}, + task: task, + user: u, + } + + // Edit the task: + taskResource, err := storage.UpdateResource(taskUID, taskContent) + assert.NoError(t, err) + + // Check that the result CALDAV contains the new relation: + content, _ := taskResource.GetContentData() + assert.Contains(t, content, "UID:"+taskUID) + assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task") + + // Get the task from the DB: + tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u) + assert.NoError(t, err) + task = tasks[0] + + // Check that the parent-child relationship is gone: + assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 0) + + // Get the previous parent from the DB and check that its child is gone: + tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u) + assert.NoError(t, err) + task = tasks[0] + // We're gone, but our former sibling is still there: + assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1) + formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0] + assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID) + }) +}