Compare commits

...

3 Commits

Author SHA1 Message Date
Erwan Martin 476bf072b1 Rebase from origin/main 2023-03-22 16:21:16 +01:00
Erwan Martin d74334a590 feat(caldav): CALDAV subtask import support 2023-03-22 14:30:08 +01:00
Erwan Martin c06f35e6a0 feat(caldav): CALDAV export subtask support
When exporting tasks in CALDAV, export attribute "Related-To" with the "PARENT" relationship, so that clients can display tasks with their proper hierarchy.
2023-03-22 09:53:06 +01:00
18 changed files with 807 additions and 62 deletions

1
go.mod
View File

@ -65,6 +65,7 @@ require (
github.com/wneessen/go-mail v0.3.8
github.com/yuin/goldmark v1.5.4
golang.org/x/crypto v0.7.0
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0
golang.org/x/image v0.6.0
golang.org/x/oauth2 v0.6.0
golang.org/x/sync v0.1.0

2
go.sum
View File

@ -782,6 +782,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo=
golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=

View File

@ -51,20 +51,20 @@ 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
Summary string
Description string
Completed time.Time
Organizer *user.User
Priority int64 // 0-9, 1 is highest
RelatedToParentUID string
Color string
Categories []string
Start time.Time
End time.Time
DueDate time.Time
Duration time.Duration
RepeatAfter int64
RepeatMode models.TaskRepeatMode
Created time.Time
Updated time.Time // last-mod
@ -209,9 +209,9 @@ STATUS:COMPLETED`
ORGANIZER;CN=:` + t.Organizer.Username
}
if t.RelatedToUID != "" {
if t.RelatedToParentUID != "" {
caldavtodos += `
RELATED-TO:` + t.RelatedToUID
RELATED-TO;RELTYPE=PARENT:` + t.RelatedToParentUID
}
if t.DueDate.Unix() > 0 {

View File

@ -520,6 +520,45 @@ X-FUNAMBOL-COLOR:#affffeFF
CATEGORIES:label1,label2
LAST-MODIFIED:00010101T000000Z
END:VTODO
END:VCALENDAR`,
},
{
name: "with parent task",
args: args{
config: &Config{
Name: "test",
ProdID: "RandomProdID which is not random",
Color: "ffffff",
},
todos: []*Todo{
{
Summary: "Todo #1",
UID: "randommduid",
Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()),
Color: "affffe",
RelatedToParentUID: "another_random_uid",
},
},
},
wantCaldavtasks: `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:test
PRODID:-//RandomProdID which is not random//EN
X-APPLE-CALENDAR-COLOR:#ffffffFF
X-OUTLOOK-COLOR:#ffffffFF
X-FUNAMBOL-COLOR:#ffffffFF
BEGIN:VTODO
UID:randommduid
DTSTAMP:20181201T011204Z
SUMMARY:Todo #1
X-APPLE-CALENDAR-COLOR:#affffeFF
X-OUTLOOK-COLOR:#affffeFF
X-FUNAMBOL-COLOR:#affffeFF
RELATED-TO;RELTYPE=PARENT:another_random_uid
LAST-MODIFIED:00010101T000000Z
END:VTODO
END:VCALENDAR`,
},
}

View File

@ -39,6 +39,12 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
categories = append(categories, label.Title)
}
// Find the UID of the parent task, if it exists:
var parentTaskUID string
if parentTasks, ok := t.RelatedTasks[models.RelationKindParenttask]; ok {
parentTaskUID = parentTasks[0].UID
}
caldavtodos = append(caldavtodos, &Todo{
Timestamp: t.Updated,
UID: t.UID,
@ -46,16 +52,17 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
Description: t.Description,
Completed: t.DoneAt,
// Organizer: &t.CreatedBy, // Disabled until we figure out how this works
Categories: categories,
Priority: t.Priority,
Start: t.StartDate,
End: t.EndDate,
Created: t.Created,
Updated: t.Updated,
DueDate: t.DueDate,
Duration: duration,
RepeatAfter: t.RepeatAfter,
RepeatMode: t.RepeatMode,
Categories: categories,
Priority: t.Priority,
RelatedToParentUID: parentTaskUID,
Start: t.StartDate,
End: t.EndDate,
Created: t.Created,
Updated: t.Updated,
DueDate: t.DueDate,
Duration: duration,
RepeatAfter: t.RepeatAfter,
RepeatMode: t.RepeatMode,
})
}
@ -73,9 +80,11 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
return nil, err
}
var parsedProperties = parsed.Components[0].UnknownPropertiesIANAProperties()
// We put the task details in a map to be able to handle them more easily
task := make(map[string]string)
for _, c := range parsed.Components[0].UnknownPropertiesIANAProperties() {
for _, c := range parsedProperties {
task[c.IANAToken] = c.Value
}
@ -90,6 +99,26 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
priority = parseVTODOPriority(priorityParsed)
}
// Check if the task has a parent:
var parentTaskUID string
for _, c := range parsedProperties {
// Check if the entry is a relation:
if c.IANAToken != "RELATED-TO" {
continue
}
// Check if the relation has a type:
if _, ok := c.ICalParameters["RELTYPE"]; !ok {
continue
}
// Check that the type is "PARENT":
if len(c.ICalParameters["RELTYPE"]) != 1 || c.ICalParameters["RELTYPE"][0] != "PARENT" {
continue
}
// We have the id of the parent task:
parentTaskUID = c.Value
}
// Parse the enddate
duration, _ := time.ParseDuration(task["DURATION"])
@ -123,6 +152,19 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
vTask.Done = true
}
// Check if the task has a parent and create a dummy relation if yes:
if parentTaskUID != "" {
var parentTaskUID = parentTaskUID
if vTask.RelatedTasks == nil {
vTask.RelatedTasks = make(models.RelatedTaskMap)
}
vTask.RelatedTasks[models.RelationKindParenttask] = append(vTask.RelatedTasks[models.RelationKindParenttask], &models.Task{
UID: parentTaskUID,
})
}
if duration > 0 && !vTask.StartDate.IsZero() {
vTask.EndDate = vTask.StartDate.Add(duration)
}

View File

@ -118,6 +118,63 @@ 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
RELATED-TO;RELTYPE=PARENT:randomuid_parent
LAST-MODIFIED:00010101T000000
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 non-parent relation we ignore",
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 task
DESCRIPTION:Lorem Ipsum
RELATED-TO;RELTYPE=CHILD:randomuid_child
LAST-MODIFIED:00010101T000000
END:VTODO
END:VCALENDAR`,
},
wantVTask: &models.Task{
Title: "Parent task",
UID: "randomuid",
Description: "Lorem Ipsum",
Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -135,8 +192,8 @@ END:VCALENDAR`,
func TestGetCaldavTodosForTasks(t *testing.T) {
type args struct {
list *models.ProjectWithTasksAndBuckets
tasks []*models.TaskWithComments
project *models.ProjectWithTasksAndBuckets
tasks []*models.TaskWithComments
}
tests := []struct {
name string
@ -146,9 +203,9 @@ func TestGetCaldavTodosForTasks(t *testing.T) {
{
name: "Format single Task as Caldav",
args: args{
list: &models.ProjectWithTasksAndBuckets{
project: &models.ProjectWithTasksAndBuckets{
Project: models.Project{
Title: "List title",
Title: "Project title",
},
},
tasks: []*models.TaskWithComments{
@ -183,7 +240,7 @@ func TestGetCaldavTodosForTasks(t *testing.T) {
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:List title
X-WR-CALNAME:Project title
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:randomuid
@ -201,12 +258,126 @@ RRULE:FREQ=SECONDLY;INTERVAL=86400
CATEGORIES:label1,label2
LAST-MODIFIED:20181201T011205Z
END:VTODO
END:VCALENDAR`,
},
{
name: "Format tasks with relationship as Caldav",
args: args{
project: &models.ProjectWithTasksAndBuckets{
Project: models.Project{
Title: "Project title",
},
},
tasks: []*models.TaskWithComments{
{
Task: models.Task{
Title: "Parent task",
UID: "randomuid_parent",
Description: "This is 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: "This is 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: "This is 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: "This is 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: "This is a parent task",
Priority: 3,
Created: time.Unix(1543626721, 0).In(config.GetTimeZone()),
},
},
},
},
},
{
Task: models.Task{
Title: "Subtask 2",
UID: "randomuid_child_2",
Description: "This is 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: "This is a parent task",
Priority: 3,
Created: time.Unix(1543626721, 0).In(config.GetTimeZone()),
},
},
},
},
},
},
},
wantCaldav: `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project title
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:randomuid_parent
DTSTAMP:20181201T011205Z
SUMMARY:Parent task
DESCRIPTION:This is a parent task
CREATED:20181201T011201Z
PRIORITY:3
LAST-MODIFIED:20181201T011205Z
END:VTODO
BEGIN:VTODO
UID:randomuid_child_1
DTSTAMP:20181201T011204Z
SUMMARY:Subtask 1
DESCRIPTION:This is the first child task
RELATED-TO;RELTYPE=PARENT:randomuid_parent
CREATED:20181201T011204Z
LAST-MODIFIED:20181201T011204Z
END:VTODO
BEGIN:VTODO
UID:randomuid_child_2
DTSTAMP:20181201T011204Z
SUMMARY:Subtask 2
DESCRIPTION:This is the second child task
RELATED-TO;RELTYPE=PARENT:randomuid_parent
CREATED:20181201T011204Z
LAST-MODIFIED:20181201T011204Z
END:VTODO
END:VCALENDAR`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetCaldavTodosForTasks(tt.args.list, tt.args.tasks)
got := GetCaldavTodosForTasks(tt.args.project, tt.args.tasks)
if diff, equal := messagediff.PrettyDiff(got, tt.wantCaldav); !equal {
t.Errorf("GetCaldavTodosForTasks() gotVTask = %v, want %v, diff = %s", got, tt.wantCaldav, diff)
}

View File

@ -224,3 +224,9 @@
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 37
title: testbucket37
project_id: 27
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52

View File

@ -255,7 +255,7 @@
created: 2018-12-01 15:13:12
-
id: 26
title: List 26 for Caldav tests
title: Project 26 for Caldav tests
description: Lorem Ipsum
identifier: test26
owner_id: 15
@ -263,3 +263,14 @@
position: 1
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
-
id: 27
title: Project 27 for Caldav tests
description: Lorem Ipsum
identifier: test27
owner_id: 15
namespace_id: 18
position: 2
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -52,3 +52,9 @@
right: 0
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12
- id: 10
user_id: 15
project_id: 27
right: 0
updated: 2018-12-02 15:13:12
created: 2018-12-01 15:13:12

View File

@ -59,7 +59,7 @@ func InitTestFileFixtures(t *testing.T) {
}
// InitTests handles the actual bootstrapping of the test env
func InitTests() {
func InitTests(loadFixtures bool) {
var err error
x, err = db.CreateTestEngine()
if err != nil {
@ -71,9 +71,11 @@ func InitTests() {
log.Fatal(err)
}
err = db.InitTestFixtures("files")
if err != nil {
log.Fatal(err)
if loadFixtures {
err = db.InitTestFixtures("files")
if err != nil {
log.Fatal(err)
}
}
InitTestFileHandler()

View File

@ -23,6 +23,6 @@ import (
// TestMain is the main test function used to bootstrap the test env
func TestMain(m *testing.M) {
InitTests()
InitTests(true)
os.Exit(m.Run())
}

View File

@ -28,7 +28,7 @@ const vtodo = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:List 26 for Caldav tests
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid
@ -42,22 +42,22 @@ 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": "26"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "26"}, true)
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:List 26 for Caldav tests")
assert.Contains(t, rec.Body.String(), "X-WR-CALNAME:Project 26 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": "26", "task": "uid"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "26", "task": "uid"}, true)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Export VTODO", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid-caldav-test"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid-caldav-test"}, true)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "SUMMARY:Title Caldav Test")
@ -67,3 +67,366 @@ func TestCaldav(t *testing.T) {
assert.Contains(t, rec.Body.String(), "CATEGORIES:Label #4")
})
}
// Here we check that the CALDAV implementation correctly supports subtasks:
func TestCaldavSubtasks(t *testing.T) {
const vtodoParentTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_parent_task
DTSTAMP:20230301T073337Z
SUMMARY:Caldav parent task
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
const vtodoChildTask1 = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 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_parent_task
END:VTODO
END:VCALENDAR`
const vtodoChildTask2 = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child2
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 2
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_parent_task
END:VTODO
END:VCALENDAR`
const vtodoGrandChildTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_grand_child
DTSTAMP:20230301T073337Z
SUMMARY:Caldav grand child task
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_child1
END:VTODO
END:VCALENDAR`
t.Run("Import parent task", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, true)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Import children tasks", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "26", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
rec, err = newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask2, nil, map[string]string{"project": "26", "task": "uid_child2"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Import grand child task", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_parent_task")
assert.NotContains(t, rec.Body.String(), "RELATED-TO")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_child1")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child2"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_child2")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_grand_child")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1")
})
const vtodoEditedGrandChildTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_grand_child
DTSTAMP:20230301T073337Z
SUMMARY:Caldav grand child task edited
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_child1
END:VTODO
END:VCALENDAR`
t.Run("Update the grand child task again and check that the relation is still there", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoEditedGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_grand_child")
assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav grand child task edited")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1")
})
const vtodoChildTask2WithoutRelation = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child2
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 2
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
t.Run("Remove the relation from the second child", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask2WithoutRelation, nil, map[string]string{"project": "26", "task": "uid_child2"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
// Check that the relation was removed from the DB, and isn't returned anymore:
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child2"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_child2")
assert.NotContains(t, rec.Body.String(), "RELATED-TO")
})
const vtodoGrandChildTaskNewParent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_grand_child
DTSTAMP:20230301T073337Z
SUMMARY:Caldav grand child task edited
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_parent_task
END:VTODO
END:VCALENDAR`
t.Run("Update the grand child task again and change its parent", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTaskNewParent, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_grand_child")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task")
})
}
// Here we check that the CALDAV implementation correctly supports task relations from different lists:
func TestCaldavSubtasksDifferentLists(t *testing.T) {
const vtodoParentTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_parent_task
DTSTAMP:20230301T073337Z
SUMMARY:Caldav parent task
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
const vtodoChildTask1 = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 27 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_parent_task
END:VTODO
END:VCALENDAR`
t.Run("Import parent task", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, true)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Import child tasks into a different list", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "27", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_parent_task")
assert.NotContains(t, rec.Body.String(), "RELATED-TO")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "27", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_child1")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task")
})
}
// Here we check that subtasks are handled properly even if the children tasks are created before the parent tasks
func TestCaldavSubtasksInverseOrder(t *testing.T) {
const vtodoGrandChildTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_grand_child
DTSTAMP:20230301T073337Z
SUMMARY:Caldav grand child task
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_child1
END:VTODO
END:VCALENDAR`
const vtodoChildTask1 = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 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_parent_task
END:VTODO
END:VCALENDAR`
const vtodoParentTask = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 26 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_parent_task
DTSTAMP:20230301T073337Z
SUMMARY:Caldav parent task
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
t.Run("Import grand child task", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, true)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Import children tasks", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "26", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Import parent task", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 201)
})
t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) {
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_parent_task")
assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav parent task")
assert.NotContains(t, rec.Body.String(), "RELATED-TO")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child1"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_child1")
assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav child task 1")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task")
rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false)
assert.NoError(t, err)
assert.Equal(t, rec.Result().StatusCode, 200)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "UID:uid_grand_child")
assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav grand child task")
assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1")
})
}

View File

@ -58,20 +58,22 @@ var (
}
)
func setupTestEnv() (e *echo.Echo, err error) {
func setupTestEnv(loadFixtures bool) (e *echo.Echo, err error) {
config.InitDefaultConfig()
// We need to set the root path even if we're not using the config, otherwise fixtures are not loaded correctly
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
// Some tests use the file engine, so we'll need to initialize that
files.InitTests()
files.InitTests(loadFixtures)
user.InitTests()
models.SetupTests()
events.Fake()
keyvalue.InitStorage()
err = db.LoadFixtures()
if err != nil {
return
if loadFixtures {
err = db.LoadFixtures()
if err != nil {
return
}
}
e = routes.NewEcho()
@ -79,9 +81,9 @@ 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) {
func bootstrapTestRequest(t *testing.T, method string, payload string, queryParam url.Values, loadFixtures bool) (c echo.Context, rec *httptest.ResponseRecorder) {
// Setup
e, err := setupTestEnv()
e, err := setupTestEnv(loadFixtures)
assert.NoError(t, err)
// Do the actual request
@ -95,7 +97,7 @@ func bootstrapTestRequest(t *testing.T, method string, payload string, queryPara
}
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)
rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true)
err = handler(c)
return
}
@ -124,8 +126,8 @@ 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)
func testRequestSetup(t *testing.T, method string, payload string, queryParams url.Values, urlParams map[string]string, loadFixtures bool) (rec *httptest.ResponseRecorder, c echo.Context) {
c, rec = bootstrapTestRequest(t, method, payload, queryParams, loadFixtures)
var paramNames []string
var paramValues []string
@ -139,21 +141,21 @@ func testRequestSetup(t *testing.T, method string, payload string, queryParams u
}
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)
rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true)
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)
rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true)
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, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string, loadFixtures bool) (rec *httptest.ResponseRecorder, err error) {
rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, loadFixtures)
c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextPlain)
result, _ := caldav.BasicAuth(user.Username, "1234", c)

View File

@ -59,7 +59,7 @@ func TestMain(m *testing.M) {
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
// Some tests use the file engine, so we'll need to initialize that
files.InitTests()
files.InitTests(true)
user.InitTests()

View File

@ -30,7 +30,7 @@ import (
// TestMain is the main test function used to bootstrap the test env
func TestMain(m *testing.M) {
user.InitTests()
files.InitTests()
files.InitTests(true)
models.SetupTests()
events.Fake()
os.Exit(m.Run())

View File

@ -36,7 +36,7 @@ func TestMain(m *testing.M) {
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
// Some tests use the file engine, so we'll need to initialize that
files.InitTests()
files.InitTests(true)
user.InitTests()
models.SetupTests()
events.Fake()

View File

@ -36,7 +36,7 @@ func TestMain(m *testing.M) {
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
// Some tests use the file engine, so we'll need to initialize that
files.InitTests()
files.InitTests(true)
user.InitTests()
models.SetupTests()
events.Fake()

View File

@ -17,6 +17,7 @@
package caldav
import (
"golang.org/x/exp/slices"
"strconv"
"strings"
"time"
@ -294,6 +295,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
}
@ -318,6 +326,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
// Explicitely 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()
@ -345,6 +357,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
}
@ -432,6 +450,88 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod
return task.UpdateTaskLabels(s, a, labels)
}
// When a VTODO entry doesn't have a parent anymore, but we do, we need to remove it as well.
func removeLegacyParentRelations(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}
err = existingTask.ReadOne(s, a)
if err != nil {
return
}
// Loop through all the existing task's parent relationships:
if _, ok := existingTask.RelatedTasks[models.RelationKindParenttask]; ok {
for _, parentTask := range existingTask.RelatedTasks[models.RelationKindParenttask] {
// Check if the existing parent relation is in the new list:
parentRelationInNewList := slices.ContainsFunc(newRelations[models.RelationKindParenttask], func(newRelation *models.Task) bool { return newRelation.UID == parentTask.UID })
// Remove the relations if it's not there in the new list anymore:
if !parentRelationInNewList {
rel := models.TaskRelation{
TaskID: task.ID,
OtherTaskID: parentTask.ID,
RelationKind: models.RelationKindParenttask,
}
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) {
// Remove existing "parent" relations that are not present in the new list:
err = removeLegacyParentRelations(s, a, task, newRelations)
if err != nil {
return err
}
// Ensure the current relations exist:
for relationType, relatedTasks := range newRelations {
// Persist each relation independently:
for _, relatedTask := range relatedTasks {
// Get the task from the DB:
has, err := s.Get(relatedTask)
if err != nil {
return err
}
// 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:
if !has {
relatedTask.ProjectID = task.ProjectID
relatedTask.Title = "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