package httpapi import ( "net/http" "testing" "time" "github.com/kristianwind/verdande/internal/store" ) // userDate is a calendar day relative to today **in the test user's timezone**, // which is what the API resolves dates in. // // Not the process's local date. CI runs in UTC, and for a Copenhagen user the two // disagree for two hours out of every twenty-four — so a test written against // time.Now() locally passes all day and fails on a late-evening push, which is the // worst kind of flake to chase. func userDate(t *testing.T, offsetDays int) string { t.Helper() loc, err := time.LoadLocation("Europe/Copenhagen") if err != nil { t.Fatalf("load timezone: %v", err) } return time.Now().In(loc).AddDate(0, 0, offsetDays).Format("2006-01-02") } // newUser creates an account directly in the store and returns a client signed in // as them. Going through the invite flow for every fixture would test the invite // flow over and over rather than the thing each test is about. func (ts *testServer) newUser(t *testing.T, email, name string) *testServer { t.Helper() hash := mustHash(t, "et langt kodeord") u := &store.User{Email: email, Name: name, PasswordHash: hash} if err := ts.db.CreateUser(t.Context(), u, "Indbakke"); err != nil { t.Fatalf("create user %s: %v", email, err) } client := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)} resp, _ := client.do(t, "POST", "/api/v1/auth/login", map[string]string{ "email": email, "password": "et langt kodeord", }) if resp.StatusCode != http.StatusOK { t.Fatalf("log in as %s: status %d", email, resp.StatusCode) } return client } func TestCreateTaskGoesToTheInboxByDefault(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) resp, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "køb kaffe"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create task: %d %v", resp.StatusCode, body) } if body["content"] != "køb kaffe" { t.Errorf("content = %v", body["content"]) } if body["priority"] != float64(4) { t.Errorf("priority = %v, want 4 by default", body["priority"]) } user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") if err != nil { t.Fatal(err) } inbox, err := ts.db.InboxID(t.Context(), user.ID) if err != nil { t.Fatal(err) } if body["project_id"] != inbox { t.Errorf("project_id = %v, want the Inbox %s", body["project_id"], inbox) } } // Quick add is the headline feature: one line in, a fully-formed task out. func TestQuickAddParsesAndFiles(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) // A project for the "#Firma" in the text to land in. resp, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create project: %d %v", resp.StatusCode, project) } resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ "text": "betal moms i morgen kl 10 p1 #Firma @regnskab", }) if resp.StatusCode != http.StatusCreated { t.Fatalf("quick add: %d %v", resp.StatusCode, body) } if body["content"] != "betal moms" { t.Errorf("content = %v, want %q", body["content"], "betal moms") } if body["priority"] != float64(1) { t.Errorf("priority = %v, want 1", body["priority"]) } if body["project_id"] != project["id"] { t.Errorf("task did not land in #Firma: %v", body["project_id"]) } tomorrow := userDate(t, 1) if body["due_date"] != tomorrow { t.Errorf("due_date = %v, want %s", body["due_date"], tomorrow) } labels, _ := body["labels"].([]any) if len(labels) != 1 || labels[0] != "regnskab" { t.Errorf("labels = %v, want [regnskab]", labels) } } // An unrecognised "#name" must not throw the thought away. func TestQuickAddWithUnknownProjectStillCreatesTheTask(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ "text": "ring til tandlægen #FindesIkke", }) if resp.StatusCode != http.StatusCreated { t.Fatalf("quick add: %d %v", resp.StatusCode, body) } if body["content"] != "ring til tandlægen" { t.Errorf("content = %v", body["content"]) } } func TestQuickAddPreviewDoesNotSave(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) resp, body := ts.do(t, "GET", "/api/v1/tasks/quick-add/preview?text=betal+moms+i+morgen+p1", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("preview: %d %v", resp.StatusCode, body) } if body["content"] != "betal moms" { t.Errorf("content = %v", body["content"]) } // Spans are what the input box highlights with. if spans, _ := body["spans"].([]any); len(spans) == 0 { t.Error("no spans returned; the input box has nothing to highlight") } _, list := ts.do(t, "GET", "/api/v1/tasks", nil) if tasks, _ := list["tasks"].([]any); len(tasks) != 0 { t.Errorf("preview created %d tasks", len(tasks)) } } func TestCompleteAndReopen(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "en opgave"}) id := task["id"].(string) resp, body := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil) if resp.StatusCode != http.StatusOK || body["completed"] != true { t.Fatalf("complete: %d %v", resp.StatusCode, body) } // A completed task drops out of the default list. _, list := ts.do(t, "GET", "/api/v1/tasks", nil) if tasks, _ := list["tasks"].([]any); len(tasks) != 0 { t.Errorf("a completed task is still in the open list") } resp, body = ts.do(t, "POST", "/api/v1/tasks/"+id+"/reopen", nil) if resp.StatusCode != http.StatusOK || body["completed"] != false { t.Fatalf("reopen: %d %v", resp.StatusCode, body) } } // Completing a parent closes what is under it — a parent standing complete over // unfinished children is a state the interface would then have to explain. func TestCompletingAParentCompletesItsSubtasks(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "flyt"}) parentID := parent["id"].(string) _, child := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "pak køkkenet", "parent_id": parentID, }) childID := child["id"].(string) _, grandchild := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "pak tallerkener", "parent_id": childID, }) grandchildID := grandchild["id"].(string) ts.do(t, "POST", "/api/v1/tasks/"+parentID+"/complete", nil) for _, id := range []string{childID, grandchildID} { _, body := ts.do(t, "GET", "/api/v1/tasks/"+id, nil) if body["completed"] != true { t.Errorf("task %s was left open under a completed parent", id) } } } func TestDeleteIsRecoverable(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "slettes"}) id := task["id"].(string) resp, _ := ts.do(t, "DELETE", "/api/v1/tasks/"+id, nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("delete: %d", resp.StatusCode) } resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+id, nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("a deleted task is still readable: %d", resp.StatusCode) } // Still in the database, restorable for thirty days. if err := ts.db.RestoreTask(t.Context(), id); err != nil { t.Fatalf("restore: %v", err) } resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+id, nil) if resp.StatusCode != http.StatusOK { t.Errorf("a restored task is not readable: %d", resp.StatusCode) } } func TestTodayAndUpcoming(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) today := userDate(t, 0) yesterday := userDate(t, -1) inThreeDays := userDate(t, 3) for _, tc := range []struct{ content, due string }{ {"forfaldt i går", yesterday}, {"i dag", today}, {"om tre dage", inThreeDays}, {"ingen dato", ""}, } { body := map[string]any{"content": tc.content} if tc.due != "" { body["due_date"] = tc.due } resp, out := ts.do(t, "POST", "/api/v1/tasks", body) if resp.StatusCode != http.StatusCreated { t.Fatalf("create %q: %d %v", tc.content, resp.StatusCode, out) } } _, todayView := ts.do(t, "GET", "/api/v1/today", nil) overdue, _ := todayView["overdue"].([]any) due, _ := todayView["today"].([]any) if len(overdue) != 1 { t.Errorf("overdue has %d tasks, want 1", len(overdue)) } if len(due) != 1 { t.Errorf("today has %d tasks, want 1", len(due)) } _, upcoming := ts.do(t, "GET", "/api/v1/upcoming", nil) days, _ := upcoming["days"].([]any) if len(days) != 7 { t.Fatalf("upcoming returned %d days, want 7", len(days)) } // Empty days are present on purpose: the view is a calendar strip. var found int for _, d := range days { day := d.(map[string]any) tasks, _ := day["tasks"].([]any) found += len(tasks) } if found != 2 { t.Errorf("upcoming contains %d tasks over 7 days, want 2", found) } } func TestSearchFindsAcrossProjects(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "køb grøn maling"}) ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ring til Anders"}) // Typed without the Danish letter, as somebody on the wrong keyboard would. resp, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("search: %d %v", resp.StatusCode, body) } tasks, _ := body["tasks"].([]any) if len(tasks) != 1 { t.Fatalf("search for 'gron' returned %d tasks, want 1", len(tasks)) } if tasks[0].(map[string]any)["content"] != "køb grøn maling" { t.Errorf("wrong task: %v", tasks[0]) } } func TestDragAndDropOrdering(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) ids := make([]string, 3) for i, name := range []string{"første", "anden", "tredje"} { _, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": name}) ids[i] = body["id"].(string) } // Drag the third task to the top: before the first, after nothing. resp, body := ts.do(t, "POST", "/api/v1/tasks/"+ids[2]+"/move", map[string]any{ "before_id": ids[0], }) if resp.StatusCode != http.StatusOK { t.Fatalf("move: %d %v", resp.StatusCode, body) } _, list := ts.do(t, "GET", "/api/v1/tasks", nil) tasks, _ := list["tasks"].([]any) if len(tasks) != 3 { t.Fatalf("got %d tasks", len(tasks)) } if got := tasks[0].(map[string]any)["content"]; got != "tredje" { t.Errorf("first task is %v, want tredje", got) } } // Repeatedly dropping into the same gap halves it each time. Eventually the // midpoint of two neighbours equals one of them, and the list has to be renumbered // rather than silently stop reordering. func TestOrderingSurvivesManyDropsIntoTheSameGap(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "a"}) _, b := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "b"}) topID, bottomID := a["id"].(string), b["id"].(string) var lastID string for i := 0; i < 80; i++ { _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "mellem"}) id := task["id"].(string) resp, body := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{ "after_id": topID, "before_id": bottomID, }) if resp.StatusCode != http.StatusOK { t.Fatalf("move %d: %d %v", i, resp.StatusCode, body) } lastID = id } // The most recent insert must still sit between the two anchors. _, list := ts.do(t, "GET", "/api/v1/tasks", nil) tasks, _ := list["tasks"].([]any) var posLast, posTop, posBottom = -1, -1, -1 for i, raw := range tasks { switch raw.(map[string]any)["id"] { case lastID: posLast = i case topID: posTop = i case bottomID: posBottom = i } } if posTop < 0 || posBottom < 0 || posLast < 0 { t.Fatal("a task went missing") } if !(posTop < posLast && posLast < posBottom) { t.Errorf("ordering broke down: top=%d last=%d bottom=%d", posTop, posLast, posBottom) } } // --- permissions, through the API ------------------------------------------------- // The promise of sharing: a viewer reads and cannot write. func TestViewerCannotMutateThroughTheAPI(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) viewer := ts.newUser(t, "viewer@example.dk", "Viewer") _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"}) projectID := project["id"].(string) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "en opgave", "project_id": projectID, }) taskID := task["id"].(string) resp, body := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{ "email": "viewer@example.dk", "role": "viewer", }) if resp.StatusCode != http.StatusOK { t.Fatalf("invite: %d %v", resp.StatusCode, body) } // Reading works. if r, _ := viewer.do(t, "GET", "/api/v1/projects/"+projectID, nil); r.StatusCode != http.StatusOK { t.Fatalf("viewer cannot read the project they were given: %d", r.StatusCode) } if r, _ := viewer.do(t, "GET", "/api/v1/tasks/"+taskID, nil); r.StatusCode != http.StatusOK { t.Errorf("viewer cannot read a task: %d", r.StatusCode) } // Nothing else does. writes := []struct { name, method, path string body any }{ {"edit a task", "PATCH", "/api/v1/tasks/" + taskID, map[string]any{"content": "ændret"}}, {"complete a task", "POST", "/api/v1/tasks/" + taskID + "/complete", nil}, {"reopen a task", "POST", "/api/v1/tasks/" + taskID + "/reopen", nil}, {"delete a task", "DELETE", "/api/v1/tasks/" + taskID, nil}, {"move a task", "POST", "/api/v1/tasks/" + taskID + "/move", map[string]any{}}, {"create a task", "POST", "/api/v1/tasks", map[string]any{"content": "ny", "project_id": projectID}}, {"add a section", "POST", "/api/v1/projects/" + projectID + "/sections", map[string]any{"name": "Ny"}}, {"rename the project", "PATCH", "/api/v1/projects/" + projectID, map[string]any{"name": "Kapret"}}, {"delete the project", "DELETE", "/api/v1/projects/" + projectID, nil}, {"invite somebody", "POST", "/api/v1/projects/" + projectID + "/invites", map[string]any{"email": "x@y.dk"}}, } for _, tc := range writes { t.Run(tc.name, func(t *testing.T) { resp, _ := viewer.do(t, tc.method, tc.path, tc.body) if resp.StatusCode < 400 { t.Errorf("a viewer could %s: status %d", tc.name, resp.StatusCode) } }) } // And nothing actually changed. _, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil) if after["content"] != "en opgave" || after["completed"] != false { t.Errorf("the task was modified by a viewer: %v", after) } } func TestEditorCanWriteButNotManage(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) editor := ts.newUser(t, "editor@example.dk", "Editor") _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"}) projectID := project["id"].(string) ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{ "email": "editor@example.dk", "role": "editor", }) resp, body := editor.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "skrevet af editor", "project_id": projectID, }) if resp.StatusCode != http.StatusCreated { t.Fatalf("editor cannot create a task: %d %v", resp.StatusCode, body) } // Managing the project itself is the owner's. for _, tc := range []struct{ name, method, path string }{ {"rename", "PATCH", "/api/v1/projects/" + projectID}, {"delete", "DELETE", "/api/v1/projects/" + projectID}, {"invite", "POST", "/api/v1/projects/" + projectID + "/invites"}, } { t.Run(tc.name, func(t *testing.T) { resp, _ := editor.do(t, tc.method, tc.path, map[string]any{"name": "x", "email": "a@b.dk"}) if resp.StatusCode < 400 { t.Errorf("an editor could %s the project: %d", tc.name, resp.StatusCode) } }) } } // An external user must see nothing but what has actually been shared with them. func TestExternalUserSeesOnlySharedProjects(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) outsider := ts.newUser(t, "ekstern@example.dk", "Ekstern") _, shared := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"}) _, private := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"}) sharedID, privateID := shared["id"].(string), private["id"].(string) _, privateTask := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "hemmelig opgave", "project_id": privateID, }) privateTaskID := privateTask["id"].(string) ts.do(t, "POST", "/api/v1/projects/"+sharedID+"/invites", map[string]any{ "email": "ekstern@example.dk", "role": "editor", }) // Their project list holds their own Inbox and the one shared project. _, list := outsider.do(t, "GET", "/api/v1/projects", nil) projects, _ := list["projects"].([]any) names := map[string]bool{} for _, p := range projects { names[p.(map[string]any)["name"].(string)] = true } if !names["Delt"] { t.Error("the shared project is missing from the external user's list") } if names["Privat"] { t.Error("an unshared project appeared in the external user's list") } // And the private one is unreachable by id, reported as absent rather than // forbidden — a 403 would confirm it exists. resp, _ := outsider.do(t, "GET", "/api/v1/projects/"+privateID, nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("private project: status %d, want 404", resp.StatusCode) } resp, _ = outsider.do(t, "GET", "/api/v1/tasks/"+privateTaskID, nil) if resp.StatusCode != http.StatusNotFound { t.Errorf("task in a private project: status %d, want 404", resp.StatusCode) } // Nor may it be reached by filtering a list at it. _, tasks := outsider.do(t, "GET", "/api/v1/tasks?project_id="+privateID, nil) if got, _ := tasks["tasks"].([]any); len(got) != 0 { t.Errorf("filtering by another user's project returned %d tasks", len(got)) } // Nor by searching for its contents. _, found := outsider.do(t, "GET", "/api/v1/search?q=hemmelig", nil) if got, _ := found["tasks"].([]any); len(got) != 0 { t.Errorf("search reached another user's tasks: %v", got) } } func TestTheInboxCannotBeDeleted(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) user, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk") if err != nil { t.Fatal(err) } inbox, err := ts.db.InboxID(t.Context(), user.ID) if err != nil { t.Fatal(err) } resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+inbox, nil) if resp.StatusCode != http.StatusConflict { t.Errorf("deleting the Inbox: status %d, want 409", resp.StatusCode) } } func TestRemovingAMemberUnassignsTheirTasks(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) member := ts.newUser(t, "medlem@example.dk", "Medlem") memberUser, err := ts.db.UserByEmail(t.Context(), "medlem@example.dk") if err != nil { t.Fatal(err) } _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"}) projectID := project["id"].(string) ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{ "email": "medlem@example.dk", "role": "editor", }) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "tildelt opgave", "project_id": projectID, "assignee_id": memberUser.ID, }) taskID := task["id"].(string) resp, _ := ts.do(t, "DELETE", "/api/v1/projects/"+projectID+"/members/"+memberUser.ID, nil) if resp.StatusCode != http.StatusNoContent { t.Fatalf("remove member: %d", resp.StatusCode) } // The task must not still point at somebody who can no longer see it. _, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil) if after["assignee_id"] != nil && after["assignee_id"] != "" { t.Errorf("assignee_id = %v after removal, want cleared", after["assignee_id"]) } // And they have lost access. if r, _ := member.do(t, "GET", "/api/v1/projects/"+projectID, nil); r.StatusCode != http.StatusNotFound { t.Errorf("a removed member still reaches the project: %d", r.StatusCode) } } func TestSectionsBelongToTheirProject(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) outsider := ts.newUser(t, "ekstern@example.dk", "Ekstern") _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"}) projectID := project["id"].(string) resp, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections", map[string]any{"name": "I gang"}) if resp.StatusCode != http.StatusCreated { t.Fatalf("create section: %d %v", resp.StatusCode, section) } sectionID := section["id"].(string) // A section id from somewhere else must not be editable by a stranger. for _, tc := range []struct{ method, path string }{ {"PATCH", "/api/v1/sections/" + sectionID}, {"DELETE", "/api/v1/sections/" + sectionID}, } { resp, _ := outsider.do(t, tc.method, tc.path, map[string]any{"name": "kapret"}) if resp.StatusCode != http.StatusNotFound { t.Errorf("%s by an outsider: status %d, want 404", tc.method, resp.StatusCode) } } // Deleting a section keeps its tasks — deleting a heading is not a request to // delete the work filed under it. _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "under sektionen", "project_id": projectID, "section_id": sectionID, }) taskID := task["id"].(string) ts.do(t, "DELETE", "/api/v1/sections/"+sectionID, nil) resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("the task went with its section: %d", resp.StatusCode) } if after["section_id"] != nil && after["section_id"] != "" { t.Errorf("section_id = %v, want cleared", after["section_id"]) } } func TestActivityRecordsWhatHappened(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"}) projectID := project["id"].(string) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "en opgave", "project_id": projectID, }) ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil) _, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/activity", nil) entries, _ := body["activity"].([]any) if len(entries) < 3 { t.Fatalf("activity has %d entries, want the create, the task and the completion", len(entries)) } events := map[string]bool{} for _, e := range entries { events[e.(map[string]any)["event"].(string)] = true } for _, want := range []string{"project.created", "task.created", "task.completed"} { if !events[want] { t.Errorf("no %s in the activity log", want) } } } // --- recurring tasks ------------------------------------------------------------ // Ticking off a repeating task does not finish it — it moves to the next occurrence. // That is the whole behaviour, and it is the one that would be most annoying to get // wrong: a weekly chore that disappears is a weekly chore you stop doing. func TestCompletingARecurringTaskAdvancesIt(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ "text": "vand planterne hver mandag", }) if resp.StatusCode != http.StatusCreated { t.Fatalf("quick add: %d %v", resp.StatusCode, task) } id := task["id"].(string) firstDue, _ := task["due_date"].(string) if task["recurrence_rule"] != "FREQ=WEEKLY;BYDAY=MO" { t.Fatalf("recurrence_rule = %v", task["recurrence_rule"]) } if firstDue == "" { t.Fatal("a repeating task was created with no date to repeat from") } resp, done := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil) if resp.StatusCode != http.StatusOK { t.Fatalf("complete: %d %v", resp.StatusCode, done) } if done["recurred"] != true { t.Errorf("recurred = %v, want true", done["recurred"]) } if done["completed"] != false { t.Error("a repeating task was closed instead of advanced") } nextDue, _ := done["due_date"].(string) if nextDue <= firstDue { t.Errorf("due_date went from %q to %q — it did not move forward", firstDue, nextDue) } // Weekly, so exactly seven days on. first, _ := time.Parse("2006-01-02", firstDue) next, _ := time.Parse("2006-01-02", nextDue) if days := int(next.Sub(first).Hours() / 24); days != 7 { t.Errorf("the task moved %d days, want 7", days) } // And it is still in the open list, not the completed one. _, list := ts.do(t, "GET", "/api/v1/tasks", nil) tasks, _ := list["tasks"].([]any) if len(tasks) != 1 { t.Fatalf("the open list has %d tasks, want the repeating one", len(tasks)) } } // The task keeps its identity across occurrences, so its sub-tasks and comments // stay attached — a "weekly review" is one thing that recurs, not fifty-two things. func TestARecurringTaskKeepsItsIdentity(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ "text": "ugentlig gennemgang hver fredag", }) id := task["id"].(string) _, child := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "læs noter", "parent_id": id, }) childID := child["id"].(string) ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil) resp, after := ts.do(t, "GET", "/api/v1/tasks/"+id, nil) if resp.StatusCode != http.StatusOK { t.Fatalf("the task disappeared: %d", resp.StatusCode) } if after["id"] != id { t.Error("the id changed across an occurrence") } resp, _ = ts.do(t, "GET", "/api/v1/tasks/"+childID, nil) if resp.StatusCode != http.StatusOK { t.Error("the sub-task was lost when its parent recurred") } } // A task due at a particular hour keeps that hour when it moves. func TestRecurrenceKeepsTheClockTime(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, task := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{ "text": "standup hverdage kl 9", }) id := task["id"].(string) before, _ := task["due_datetime"].(string) if before == "" { t.Fatal("no due_datetime was set") } _, done := ts.do(t, "POST", "/api/v1/tasks/"+id+"/complete", nil) after, _ := done["due_datetime"].(string) if after == "" { t.Fatal("the clock time was dropped when the task recurred") } parseHM := func(s string) string { v, err := time.Parse(time.RFC3339, s) if err != nil { t.Fatalf("parse %q: %v", s, err) } return v.Format("15:04") } if parseHM(before) != parseHM(after) { t.Errorf("the time changed from %s to %s", parseHM(before), parseHM(after)) } } // A completion is still recorded, even though nothing was closed. "What did I get // done this week" has to include the chores that repeat. func TestRecurringCompletionIsRecorded(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"}) projectID := project["id"].(string) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "tøm opvaskemaskinen", "project_id": projectID, "due_date": userDate(t, 0), "recurrence_rule": "FREQ=DAILY", }) ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil) _, body := ts.do(t, "GET", "/api/v1/projects/"+projectID+"/activity", nil) entries, _ := body["activity"].([]any) var found bool for _, e := range entries { entry := e.(map[string]any) if entry["event"] != "task.completed" { continue } found = true payload, _ := entry["payload"].(map[string]any) if payload["recurred"] != true { t.Errorf("the completion was not marked as a recurrence: %v", payload) } } if !found { t.Error("no completion was recorded for a repeating task") } } // A rule the RRULE library cannot read must be refused when the task is created, // not discovered when somebody ticks it off and it will not move. func TestInvalidRecurrenceIsRefused(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) resp, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{ "content": "noget", "recurrence_rule": "FREQ=FORTNIGHTLY", }) if resp.StatusCode != http.StatusUnprocessableEntity { t.Fatalf("status %d, want 422", resp.StatusCode) } fields, _ := body["fields"].(map[string]any) if fields["recurrence_rule"] == nil { t.Errorf("the error does not name the field: %v", body) } } // A non-repeating task must still complete normally. func TestCompletingAPlainTaskStillCloses(t *testing.T) { ts := newTestServer(t) ts.bootstrap(t) _, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "engangsopgave"}) _, done := ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/complete", nil) if done["completed"] != true { t.Error("a plain task was not completed") } if done["recurred"] == true { t.Error("a plain task reported as recurring") } }