WIP: feat(caldav): Add support for subtasks in CalDAV. #1442

Closed
zewaren wants to merge 13 commits from zewaren/api:feature/caldav-subtasks into main
22 changed files with 1381 additions and 88 deletions

View File

@ -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
RelatedToParentUID string
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
@ -147,9 +147,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

@ -326,13 +326,52 @@ ACTION:DISPLAY
DESCRIPTION:Todo #1
END:VALARM
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`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gotCaldavtasks := ParseTodos(tt.args.config, tt.args.todos)
assert.Equal(t, tt.wantCaldavtasks, gotCaldavtasks)
assert.Equal(t, gotCaldavtasks, tt.wantCaldavtasks)
})
}
}

View File

@ -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,12 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT
})
}
// 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,
@ -58,17 +63,18 @@ 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,
Alarms: alarms,
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,
Alarms: alarms,
})
}
@ -89,14 +95,11 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
if !ok {
return nil, errors.New("VTODO element not found")
}
var parsedProperties = vTodo.UnknownPropertiesIANAProperties()
// 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
for _, c := range vTodo.UnknownPropertiesIANAProperties() {
for _, c := range parsedProperties {
task[c.IANAToken] = c
if strings.HasPrefix(c.IANAToken, "RELATED-TO") {
relation = c
}
}
// Parse the priority
@ -110,6 +113,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"].Value)
@ -139,23 +162,23 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) {
DoneAt: caldavTimeToTimestamp(task["COMPLETED"]),
}
if relation.Value != "" {
s := db.NewSession()
defer s.Close()
subtask, err := models.GetTaskSimpleByUUID(s, relation.Value)
if err != nil {
return nil, err
}
vTask.RelatedTasks = make(map[models.RelationKind][]*models.Task)
vTask.RelatedTasks[models.RelationKindSubtask] = []*models.Task{subtask}
}
if task["STATUS"].Value == "COMPLETED" {
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

@ -289,6 +289,63 @@ END:VCALENDAR`,
},
},
},
{
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) {
@ -306,8 +363,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
@ -317,9 +374,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{
@ -364,7 +421,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
@ -392,12 +449,126 @@ ACTION:DISPLAY
DESCRIPTION:Task 1
END:VALARM
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

@ -236,3 +236,15 @@
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 39
title: testbucket37
project_id: 37
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52
- id: 40
title: testbucket38
project_id: 38
created_by_id: 15
created: 2020-04-18 21:13:52
updated: 2020-04-18 21:13:52

View File

@ -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

View File

@ -34,3 +34,27 @@
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

View File

@ -374,5 +374,61 @@
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 2'
description: 'Description Caldav Test'
priority: 3
done: false
created_by_id: 15
project_id: 36
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

View File

@ -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

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) {
Review

Why would you not want to load this? The files fixtures are mostly only required for file fixtures, but I feel like this adds unnecessary overhead.

Why would you not want to load this? The files fixtures are mostly only required for file fixtures, but I feel like this adds unnecessary overhead.
Review

So, the way the test infrastructure is currently configured on the main branch is that every HTTP request resets the database by loading all the fixtures.

This prevents us from being able to run more complicated integration tests, that require multiple HTTP calls.

The best example is the test with the grand children.
That test creates a hierarchy of tasks with multiple parent/child levels. It checks that the tasks can be synchronized in any order (example: grand-children first), with the end result being always consistent.

How would you achieve such an integration test, without being able to keep some state between HTTP requests the same way an actual live client would expect?

So, the way the test infrastructure is currently configured on the main branch is that every HTTP request resets the database by loading all the fixtures. This prevents us from being able to run more complicated integration tests, that require multiple HTTP calls. The best example is the test with the grand children. That test creates a hierarchy of tasks with multiple parent/child levels. It checks that the tasks can be synchronized in any order (example: grand-children first), with the end result being always consistent. How would you achieve such an integration test, without being able to keep some state between HTTP requests the same way an actual live client would expect?
Review

Shouldn't the relation be present already when the test starts? (loaded from fixtures)

The test you described should probably be multiple tests, one for the creation of a hierarchy, one for the modification, one to check if it is populated properly, etc.

Shouldn't the relation be present already when the test starts? (loaded from fixtures) The test you described should probably be multiple tests, one for the creation of a hierarchy, one for the modification, one to check if it is populated properly, etc.
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

@ -32,7 +32,7 @@ import (
func TestAPIToken(t *testing.T) {
t.Run("valid token", func(t *testing.T) {
e, err := setupTestEnv()
e, err := setupTestEnv(true)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
res := httptest.NewRecorder()
@ -52,7 +52,7 @@ func TestAPIToken(t *testing.T) {
assert.Contains(t, res.Body.String(), `"username":"user1"`)
})
t.Run("invalid token", func(t *testing.T) {
e, err := setupTestEnv()
e, err := setupTestEnv(true)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
res := httptest.NewRecorder()
@ -65,7 +65,7 @@ func TestAPIToken(t *testing.T) {
assert.Error(t, h(c))
})
t.Run("expired token", func(t *testing.T) {
e, err := setupTestEnv()
e, err := setupTestEnv(true)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
res := httptest.NewRecorder()
@ -78,7 +78,7 @@ func TestAPIToken(t *testing.T) {
assert.Error(t, h(c))
})
t.Run("valid token, invalid scope", func(t *testing.T) {
e, err := setupTestEnv()
e, err := setupTestEnv(true)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/projects", nil)
res := httptest.NewRecorder()
@ -91,7 +91,7 @@ func TestAPIToken(t *testing.T) {
assert.Error(t, h(c))
})
t.Run("jwt", func(t *testing.T) {
e, err := setupTestEnv()
e, err := setupTestEnv(true)
assert.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "/api/v1/tasks/all", nil)
res := httptest.NewRecorder()

View File

@ -28,7 +28,7 @@ const vtodo = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:List 36 for Caldav tests
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid
@ -46,7 +46,7 @@ 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"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "36"}, true)
assert.NoError(t, err)
assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR")
assert.Contains(t, rec.Body.String(), "PRODID:-//Vikunja Todo App//EN")
@ -56,12 +56,12 @@ func TestCaldav(t *testing.T) {
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"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "36", "task": "uid"}, true)
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"})
rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "36", "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")
@ -75,3 +75,366 @@ func TestCaldav(t *testing.T) {
assert.Contains(t, rec.Body.String(), "END:VALARM")
})
}
// 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 36 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 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_parent_task
END:VTODO
END:VCALENDAR`
const vtodoChildTask2 = `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_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 36 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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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 36 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": "36", "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": "36", "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 36 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": "36", "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": "36", "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 36 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": "36", "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": "36", "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 36 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 38 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": "36", "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": "38", "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": "36", "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": "38", "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 36 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 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_parent_task
END:VTODO
END:VCALENDAR`
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_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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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": "36", "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 {
Review

Same here, why wouldn't you want to load the fixtures? This creates a kind of unpredictable state for tests.

Same here, why wouldn't you want to load the fixtures? This creates a kind of unpredictable state for tests.
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

@ -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,

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

@ -29,6 +29,7 @@ import (
"code.vikunja.io/web"
"github.com/samedi/caldav-go/data"
"github.com/samedi/caldav-go/errs"
"golang.org/x/exp/slices"
"xorm.io/xorm"
)
@ -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,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:
Review

Why call this "legacy"?

Why call this "legacy"?
Review

Here "legacy" means that the tasks had a parent, but that parent is gone.

This case is when a CALDAV client removes the parent relationship and pushes the task to us. We still have the relationship in our DB, and we need to remove it.

I'm happy to use a better word if you have one in mind.

Here "legacy" means that the tasks had a parent, but that parent is gone. This case is when a CALDAV client removes the parent relationship and pushes the task to us. We still have the relationship in our DB, and we need to remove it. I'm happy to use a better word if you have one in mind.
Review

Something like removeDeletedParentRelations?

Something like `removeDeletedParentRelations`?
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

View File

@ -0,0 +1,444 @@
// 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 <https://www.gnu.org/licenses/>.
package caldav
// This file tests logic related to handling tasks in CALDAV format
import (
"testing"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"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",
}
//
// 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 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-child-task
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-child-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 of the grandchildren is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-child-task", parentTask.UID)
// Get the child task and check that it now has a parent and a child:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-child-task"}, 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_Edit(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
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)
})
}

View File

@ -0,0 +1,36 @@
// 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 <https://www.gnu.org/licenses/>.
package caldav
import (
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
)
// TestMain is the main test function used to bootstrap the test env
func TestMain(m *testing.M) {
config.InitDefaultConfig()
files.InitTests(true)
user.InitTests()
models.SetupTests()
m.Run()
}