Files
verdande/internal/httpapi/tasks_test.go
T
kristianwindandClaude Opus 5 094ea5cdd8 feat(upcoming): look up to 30 days ahead, and choose how far
Kommendes liste has always been the next seven days and nothing else. Seven days
is the right walk to take in the morning, and it is still what the view opens on
— but "when does this month get busy" is a different question, and the month grid
answers it with shape rather than in order. So the list now takes a horizon: 7,
14 or 30 days, chosen with a switcher beside the view buttons.

Only the list gets one. A week grid is a week and a month grid is a month; there
is no fortnight for them to show, so the switcher is hidden in those modes.

The choice lives in localStorage next to the mode, under its own key. It is the
same kind of choice as list-or-grid — how you want to look, not a fact about the
work — and it should survive a reload without following you to a phone where
thirty day headings is a very long scroll.

Once the strip is longer than a week the day headings carry the week number.
Thirty days repeat every weekday four times, and "mandag 6. okt" alone does not
say which mandag.

Server side the endpoint already took `?days=` and capped it at 31, so the window
needed nothing — but its row limit was a constant 1000 written for a week. A
month asked for with a week's ceiling would truncate the last days silently, and
a day cut off the end of the answer looks exactly like a day with nothing due, so
the limit now follows the window.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01SA1TDmgQZw3AufJqkbwezx
2026-09-07 14:46:49 +02:00

1910 lines
64 KiB
Go

package httpapi
import (
"bytes"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/url"
"strings"
"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(), "[email protected]")
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)
}
}
// The horizon is the list's, and it is asked for per request: seven days is the
// default, thirty is what Kommende offers for "when does this month get busy", and
// anything past the cap is trimmed rather than refused — a client that asks for a
// year gets a month, not a 400.
func TestUpcomingHorizon(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "om tyve dage",
"due_date": userDate(t, 20),
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create: %d %v", resp.StatusCode, out)
}
count := func(query string) (int, int) {
t.Helper()
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
days, _ := view["days"].([]any)
var tasks int
for _, d := range days {
day, _ := d.(map[string]any)
list, _ := day["tasks"].([]any)
tasks += len(list)
}
return len(days), tasks
}
if n, found := count(""); n != 7 || found != 0 {
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
}
if n, found := count("?days=30"); n != 30 || found != 1 {
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
}
if n, _ := count("?days=365"); n != 31 {
t.Errorf("days=365 returned %d days, want it capped at 31", n)
}
if n, _ := count("?days=nonsense"); n != 7 {
t.Errorf("an unreadable days returned %d days, want the default 7", n)
}
}
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, "[email protected]", "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": "[email protected]", "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": "[email protected]"}},
}
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, "[email protected]", "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": "[email protected]", "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": "[email protected]"})
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, "[email protected]", "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": "[email protected]", "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(), "[email protected]")
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, "[email protected]", "Medlem")
memberUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
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": "[email protected]", "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, "[email protected]", "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")
}
}
// --- saved filters ----------------------------------------------------------------
func TestSavedFilters(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
projectID := project["id"].(string)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "betal moms", "project_id": projectID,
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"regnskab"},
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "ryd op", "project_id": projectID, "priority": 4,
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "forsinket ting", "due_date": userDate(t, -3), "priority": 2,
})
resp, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Haster i dag", "query": "today & p1",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create filter: %d %v", resp.StatusCode, filter)
}
resp, result := ts.do(t, "GET", "/api/v1/filters/"+filter["id"].(string)+"/tasks", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("run filter: %d %v", resp.StatusCode, result)
}
tasks, _ := result["tasks"].([]any)
if len(tasks) != 1 {
t.Fatalf("the filter matched %d tasks, want 1", len(tasks))
}
if tasks[0].(map[string]any)["content"] != "betal moms" {
t.Errorf("matched the wrong task: %v", tasks[0])
}
}
func TestFilterPreviewRunsWithoutSaving(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "forsinket", "due_date": userDate(t, -2),
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ingen dato"})
resp, body := ts.do(t, "GET", "/api/v1/filters/preview?query=overdue", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("preview: %d %v", resp.StatusCode, body)
}
tasks, _ := body["tasks"].([]any)
if len(tasks) != 1 {
t.Errorf("overdue matched %d tasks, want 1", len(tasks))
}
_, list := ts.do(t, "GET", "/api/v1/filters", nil)
if saved, _ := list["filters"].([]any); len(saved) != 0 {
t.Errorf("the preview saved %d filters", len(saved))
}
}
// A filter that cannot be compiled must be refused when it is saved, not when it
// is next opened and silently returns nothing.
func TestInvalidFilterIsRefusedOnSave(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Ødelagt", "query": "today & (p1",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("status %d, want 422", resp.StatusCode)
}
fields, _ := body["fields"].(map[string]any)
if fields["query"] == nil {
t.Errorf("the error does not name the query field: %v", body)
}
}
// A filter belongs to one person and must not reach anybody else's tasks.
func TestFiltersAreScopedToTheirOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "min private opgave", "priority": 1, "due_date": userDate(t, 0),
})
_, filter := ts.do(t, "POST", "/api/v1/filters", map[string]any{
"name": "Mit filter", "query": "today & p1",
})
filterID := filter["id"].(string)
// Somebody else cannot run it.
resp, _ := other.do(t, "GET", "/api/v1/filters/"+filterID+"/tasks", nil)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("another user ran the filter: status %d", resp.StatusCode)
}
// And writing the same expression themselves finds none of the owner's tasks.
_, result := other.do(t, "GET", "/api/v1/filters/preview?query=today+%26+p1", nil)
if tasks, _ := result["tasks"].([]any); len(tasks) != 0 {
t.Errorf("an identical filter reached another user's tasks: %v", tasks)
}
}
func TestLabelsCRUD(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// Labels appear by being used, without being created first.
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "med etiket", "labels": []string{"venter"},
})
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
labels, _ := list["labels"].([]any)
if len(labels) != 1 {
t.Fatalf("got %d labels, want the one created by using it", len(labels))
}
if labels[0].(map[string]any)["task_count"] != float64(1) {
t.Errorf("task_count = %v, want 1", labels[0].(map[string]any)["task_count"])
}
resp, created := ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "haster"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create label: %d %v", resp.StatusCode, created)
}
// The same name twice is a conflict, not a second label.
resp, _ = ts.do(t, "POST", "/api/v1/labels", map[string]any{"name": "HASTER"})
if resp.StatusCode != http.StatusConflict {
t.Errorf("duplicate label: status %d, want 409", resp.StatusCode)
}
resp, _ = ts.do(t, "DELETE", "/api/v1/labels/"+created["id"].(string), nil)
if resp.StatusCode != http.StatusNoContent {
t.Errorf("delete label: status %d", resp.StatusCode)
}
}
// Deleting a label is tidying, not a request to delete the work filed under it.
func TestDeletingALabelKeepsItsTasks(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "vigtig opgave", "labels": []string{"venter"},
})
taskID := task["id"].(string)
_, list := ts.do(t, "GET", "/api/v1/labels", nil)
labels, _ := list["labels"].([]any)
labelID := labels[0].(map[string]any)["id"].(string)
ts.do(t, "DELETE", "/api/v1/labels/"+labelID, nil)
resp, after := ts.do(t, "GET", "/api/v1/tasks/"+taskID, nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("the task went with its label: %d", resp.StatusCode)
}
if got, _ := after["labels"].([]any); len(got) != 0 {
t.Errorf("labels = %v, want empty", got)
}
}
// --- reminders, feeds and templates -----------------------------------------------
func TestReminders(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "møde", "due_date": userDate(t, 0), "due_time": "14:00",
})
taskID := task["id"].(string)
// Relative: ten minutes before whenever the task is due.
offset := -10
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"offset_min": offset})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create relative reminder: %d %v", resp.StatusCode, body)
}
// Absolute.
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": time.Now().Add(time.Hour).Format(time.RFC3339)})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create absolute reminder: %d", resp.StatusCode)
}
// Both at once is not a reminder with two times; it is a mistake.
resp, _ = ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders", map[string]any{
"offset_min": offset, "remind_at": time.Now().Format(time.RFC3339),
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Errorf("a reminder with both a time and an offset: status %d, want 422", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/reminders", nil)
reminders, _ := list["reminders"].([]any)
if len(reminders) != 2 {
t.Fatalf("got %d reminders, want 2", len(reminders))
}
}
// A reminder that has come due is found by the sweep; one in the future is not,
// and neither is one on a task that has already been done.
func TestDueRemindersAreFoundOnce(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "husk"})
taskID := task["id"].(string)
past := time.Now().Add(-time.Minute)
future := time.Now().Add(time.Hour)
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": past.Format(time.RFC3339)})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": future.Format(time.RFC3339)})
due, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(due) != 1 {
t.Fatalf("got %d due reminders, want only the one in the past", len(due))
}
if due[0].TaskContent != "husk" {
t.Errorf("the reminder does not carry the task's text: %+v", due[0])
}
// Marking it sent takes it out of the sweep, so it cannot go out twice.
if err := ts.db.MarkReminderSent(t.Context(), due[0].ID); err != nil {
t.Fatal(err)
}
again, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(again) != 0 {
t.Errorf("a sent reminder came round again: %+v", again)
}
}
// A reminder for something already done is a small betrayal of the whole feature.
func TestCompletedTasksDoNotRemind(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "gjort"})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/reminders",
map[string]any{"remind_at": time.Now().Add(-time.Minute).Format(time.RFC3339)})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/complete", nil)
due, err := ts.db.DueReminders(t.Context(), time.Now())
if err != nil {
t.Fatal(err)
}
if len(due) != 0 {
t.Errorf("a completed task still reminded: %+v", due)
}
}
func TestICSFeed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "møde med revisor", "due_date": userDate(t, 1), "due_time": "10:00",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "uden dato"})
resp, body := ts.do(t, "GET", "/api/v1/feed", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("get feed: %d %v", resp.StatusCode, body)
}
feedURL, _ := body["url"].(string)
if feedURL == "" {
t.Fatal("no feed URL was returned")
}
// The feed itself is fetched without a session, as a calendar client would.
path := feedURL[strings.Index(feedURL, "/ics/"):]
req, err := http.NewRequest("GET", ts.URL+path, nil)
if err != nil {
t.Fatal(err)
}
raw, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
defer raw.Body.Close()
if raw.StatusCode != http.StatusOK {
t.Fatalf("fetching the feed without a session: %d", raw.StatusCode)
}
if ct := raw.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/calendar") {
t.Errorf("Content-Type = %q, want text/calendar", ct)
}
content, _ := io.ReadAll(raw.Body)
feed := string(content)
if !strings.Contains(feed, "BEGIN:VCALENDAR") {
t.Error("the feed is not an iCalendar document")
}
if !strings.Contains(feed, "SUMMARY:møde med revisor") {
t.Error("the dated task is missing from the feed")
}
if strings.Contains(feed, "uden dato") {
t.Error("a task with no date appeared in a calendar")
}
// An invented token is a missing feed, not a prompt for credentials nobody
// can answer.
bad, err := (&http.Client{}).Get(ts.URL + "/ics/opfundet.ics")
if err != nil {
t.Fatal(err)
}
defer bad.Body.Close()
if bad.StatusCode != http.StatusNotFound {
t.Errorf("an unknown feed token: status %d, want 404", bad.StatusCode)
}
}
// Rotating the token is what somebody does when a feed URL has leaked, so the old
// one has to stop working immediately.
func TestRotatingTheFeedTokenBreaksTheOldURL(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, first := ts.do(t, "GET", "/api/v1/feed", nil)
oldURL := first["url"].(string)
_, second := ts.do(t, "POST", "/api/v1/feed/rotate", nil)
newURL := second["url"].(string)
if oldURL == newURL {
t.Fatal("rotating produced the same URL")
}
oldPath := oldURL[strings.Index(oldURL, "/ics/"):]
resp, err := (&http.Client{}).Get(ts.URL + oldPath)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Errorf("the old feed URL still works: status %d", resp.StatusCode)
}
}
func TestProjectTemplates(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Onboarding"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Første uge"})
sectionID := section["id"].(string)
_, parent := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opret konti", "project_id": projectID, "section_id": sectionID,
"priority": 1, "due_date": userDate(t, 0), "labels": []string{"it"},
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "udlever laptop", "project_id": projectID, "parent_id": parent["id"].(string),
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opfølgning", "project_id": projectID, "due_date": userDate(t, 14),
})
resp, tpl := ts.do(t, "POST", "/api/v1/templates", map[string]any{
"project_id": projectID, "name": "Onboarding-skabelon",
})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("save template: %d %v", resp.StatusCode, tpl)
}
if tpl["task_count"] != float64(3) {
t.Errorf("task_count = %v, want 3", tpl["task_count"])
}
// Used a month later, the dates land relative to the new start.
start := time.Now().AddDate(0, 1, 0).Format("2006-01-02")
resp, created := ts.do(t, "POST", "/api/v1/templates/"+tpl["id"].(string)+"/use",
map[string]any{"name": "Onboarding: Mette", "start_date": start})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("use template: %d %v", resp.StatusCode, created)
}
if created["name"] != "Onboarding: Mette" {
t.Errorf("name = %v", created["name"])
}
newID := created["id"].(string)
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+newID, nil)
tasks, _ := list["tasks"].([]any)
if len(tasks) != 3 {
t.Fatalf("the new project has %d tasks, want 3", len(tasks))
}
// Sections came across, sub-tasks kept their parent, and the spacing between
// the dates survived even though the dates themselves did not.
_, sections := ts.do(t, "GET", "/api/v1/projects/"+newID+"/sections", nil)
if got, _ := sections["sections"].([]any); len(got) != 1 {
t.Errorf("got %d sections, want 1", len(got))
}
var withParent, dated int
var earliest, latest string
for _, raw := range tasks {
task := raw.(map[string]any)
if task["parent_id"] != nil && task["parent_id"] != "" {
withParent++
}
if due, _ := task["due_date"].(string); due != "" {
dated++
if earliest == "" || due < earliest {
earliest = due
}
if due > latest {
latest = due
}
}
}
if withParent != 1 {
t.Errorf("%d tasks have a parent, want 1", withParent)
}
if dated != 2 {
t.Errorf("%d tasks have a date, want 2", dated)
}
if earliest != start {
t.Errorf("the first task is due %q, want the stated start %q", earliest, start)
}
first, _ := time.Parse("2006-01-02", earliest)
last, _ := time.Parse("2006-01-02", latest)
if gap := int(last.Sub(first).Hours() / 24); gap != 14 {
t.Errorf("the tasks are %d days apart, want the original 14", gap)
}
}
func TestTemplatesAreScopedToTheirOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
_, tpl := ts.do(t, "POST", "/api/v1/templates",
map[string]any{"project_id": project["id"].(string)})
tplID := tpl["id"].(string)
resp, _ := other.do(t, "POST", "/api/v1/templates/"+tplID+"/use", map[string]any{})
if resp.StatusCode != http.StatusNotFound {
t.Errorf("another user used the template: status %d", resp.StatusCode)
}
_, list := other.do(t, "GET", "/api/v1/templates", nil)
if got, _ := list["templates"].([]any); len(got) != 0 {
t.Errorf("another user's template list shows %d entries", len(got))
}
}
// --- comments, attachments and import/export -----------------------------------------
func TestCommentsAndAttachments(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "med kommentarer"})
taskID := task["id"].(string)
resp, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "første kommentar"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("create comment: %d %v", resp.StatusCode, comment)
}
if comment["user_name"] != "Kristian" {
t.Errorf("user_name = %v", comment["user_name"])
}
// A file attached directly to the task.
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", "bilag.pdf")
if err != nil {
t.Fatal(err)
}
part.Write([]byte("%PDF-1.4 ikke en rigtig pdf"))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/tasks/"+taskID+"/attachments", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
upload, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer upload.Body.Close()
if upload.StatusCode != http.StatusCreated {
t.Fatalf("upload: status %d", upload.StatusCode)
}
var attachment map[string]any
raw, _ := io.ReadAll(upload.Body)
json.Unmarshal(raw, &attachment)
if attachment["filename"] != "bilag.pdf" {
t.Errorf("filename = %v", attachment["filename"])
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
if got, _ := list["comments"].([]any); len(got) != 1 {
t.Errorf("got %d comments, want 1", len(got))
}
if got, _ := list["attachments"].([]any); len(got) != 1 {
t.Errorf("got %d attachments on the task, want 1", len(got))
}
// The file downloads, and never as something a browser would render.
dl, dlBody := ts.do(t, "GET", "/api/v1/attachments/"+attachment["id"].(string), nil)
_ = dlBody
if dl.StatusCode != http.StatusOK {
t.Fatalf("download: status %d", dl.StatusCode)
}
if ct := dl.Header.Get("Content-Type"); ct != "application/octet-stream" {
t.Errorf("Content-Type = %q; an uploaded file must never be served inline", ct)
}
if cd := dl.Header.Get("Content-Disposition"); !strings.HasPrefix(cd, "attachment") {
t.Errorf("Content-Disposition = %q, want an attachment", cd)
}
}
// A viewer can read a discussion but not join it, and cannot upload.
func TestViewersCannotComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
viewer := ts.newUser(t, "[email protected]", "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": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "[email protected]", "role": "viewer"})
ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments", map[string]any{"body": "ejerens ord"})
if r, body := viewer.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil); r.StatusCode != http.StatusOK {
t.Errorf("a viewer cannot read comments: %d %v", r.StatusCode, body)
}
if r, _ := viewer.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "smuglet ind"}); r.StatusCode < 400 {
t.Errorf("a viewer could comment: status %d", r.StatusCode)
}
}
// Somebody else's comment is theirs. A project role does not confer the right to
// rewrite what another person said.
func TestOnlyTheAuthorCanEditAComment(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
editor := ts.newUser(t, "[email protected]", "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": "[email protected]", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
_, comment := ts.do(t, "POST", "/api/v1/tasks/"+taskID+"/comments",
map[string]any{"body": "ejerens ord"})
commentID := comment["id"].(string)
resp, _ := editor.do(t, "PATCH", "/api/v1/comments/"+commentID,
map[string]any{"body": "omskrevet"})
if resp.StatusCode < 400 {
t.Errorf("an editor rewrote somebody else's comment: status %d", resp.StatusCode)
}
_, list := ts.do(t, "GET", "/api/v1/tasks/"+taskID+"/comments", nil)
comments, _ := list["comments"].([]any)
if comments[0].(map[string]any)["body"] != "ejerens ord" {
t.Error("the comment was changed")
}
}
func TestImportTodoistCSV(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
csv := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,2026-03-15,en,\n" +
"note,En kommentar,,,,,,,,\n" +
"task,Find bilag,,1,2,Kristian,,,,\n" +
"section,Til gennemsyn,,,,,,,,\n" +
"task,Gennemgå,,3,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Fra Todoist")
part, err := writer.CreateFormFile("file", "firma.csv")
if err != nil {
t.Fatal(err)
}
part.Write([]byte(csv))
writer.Close()
req, err := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
raw, _ := io.ReadAll(resp.Body)
t.Fatalf("import: status %d, %s", resp.StatusCode, raw)
}
var result map[string]any
raw, _ := io.ReadAll(resp.Body)
json.Unmarshal(raw, &result)
if result["tasks"] != float64(3) {
t.Errorf("imported %v tasks, want 3", result["tasks"])
}
if result["sections"] != float64(1) {
t.Errorf("imported %v sections, want 1", result["sections"])
}
if result["comments"] != float64(1) {
t.Errorf("imported %v comments, want 1", result["comments"])
}
projectID := result["project_id"].(string)
_, list := ts.do(t, "GET", "/api/v1/tasks?project_id="+projectID, nil)
tasks, _ := list["tasks"].([]any)
var found bool
for _, raw := range tasks {
task := raw.(map[string]any)
if task["content"] != "Betal moms" {
continue
}
found = true
// Todoist's 4 is verdande's 1. Getting this backwards would invert the
// urgency of every imported task.
if task["priority"] != float64(1) {
t.Errorf("priority = %v, want 1", task["priority"])
}
if task["due_date"] != "2026-03-15" {
t.Errorf("due_date = %v", task["due_date"])
}
}
if !found {
t.Error("the imported task was not found")
}
}
// The whole point of import and export together: what goes in comes back out.
func TestTodoistImportExportRoundTrip(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
original := "TYPE,CONTENT,DESCRIPTION,PRIORITY,INDENT,AUTHOR,RESPONSIBLE,DATE,DATE_LANG,TIMEZONE\n" +
"task,Betal moms,husk bilag,4,1,Kristian,,,,\n" +
"task,Find bilag,,2,2,Kristian,,,,\n" +
"task,Ring til revisor,,1,1,Kristian,,,,\n"
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
writer.WriteField("name", "Rundtur")
part, _ := writer.CreateFormFile("file", "firma.csv")
part.Write([]byte(original))
writer.Close()
req, _ := http.NewRequest("POST", ts.URL+"/api/v1/import/todoist", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var result map[string]any
json.Unmarshal(raw, &result)
projectID, _ := result["project_id"].(string)
if projectID == "" {
t.Fatalf("import failed: %s", raw)
}
// Export it again and compare the shape.
out, exported := ts.do(t, "GET", "/api/v1/export/projects/"+projectID+".csv", nil)
_ = exported
if out.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", out.StatusCode)
}
// The response body was consumed by `do`; fetch it as text instead.
req2, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/projects/"+projectID+".csv", nil)
req2.Header.Set("Sec-Fetch-Site", "same-origin")
csvResp, err := ts.client.Do(req2)
if err != nil {
t.Fatal(err)
}
defer csvResp.Body.Close()
csvBytes, _ := io.ReadAll(csvResp.Body)
roundTripped := string(csvBytes)
for _, want := range []string{"Betal moms", "Find bilag", "Ring til revisor", "husk bilag"} {
if !strings.Contains(roundTripped, want) {
t.Errorf("%q is missing from the export:\n%s", want, roundTripped)
}
}
// The priorities have to come back as Todoist's numbering, not verdande's.
if !strings.Contains(roundTripped, "task,Betal moms,husk bilag,4,1") {
t.Errorf("the exported row does not match Todoist's format:\n%s", roundTripped)
}
// And the sub-task comes back exactly as it went in: Todoist priority 2 became
// verdande's 3 on the way in and has to become 2 again on the way out.
if !strings.Contains(roundTripped, "task,Find bilag,,2,2") {
t.Errorf("the sub-task lost its indent or priority:\n%s", roundTripped)
}
}
func TestAccountExport(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "en opgave", "project_id": project["id"].(string), "labels": []string{"vigtig"},
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "en kommentar"})
req, _ := http.NewRequest("GET", ts.URL+"/api/v1/export/account", nil)
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ts.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("export: status %d", resp.StatusCode)
}
raw, _ := io.ReadAll(resp.Body)
var export map[string]any
if err := json.Unmarshal(raw, &export); err != nil {
t.Fatalf("the export is not valid JSON: %v", err)
}
if export["version"] != float64(1) {
t.Errorf("version = %v", export["version"])
}
// A password hash must never be in an export somebody emails to themselves.
if strings.Contains(string(raw), "$argon2id$") {
t.Error("the account export contains a password hash")
}
projects, _ := export["projects"].([]any)
if len(projects) < 2 {
t.Fatalf("got %d projects, want the Inbox and Firma", len(projects))
}
var foundComment bool
for _, p := range projects {
tasks, _ := p.(map[string]any)["tasks"].([]any)
for _, raw := range tasks {
comments, _ := raw.(map[string]any)["comments"].([]any)
if len(comments) > 0 {
foundComment = true
}
}
}
if !foundComment {
t.Error("comments are missing from the account export")
}
if labels, _ := export["labels"].([]any); len(labels) == 0 {
t.Error("labels are missing from the account export")
}
}
func TestNotifications(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "[email protected]", "Anden")
_, 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": "[email protected]", "role": "editor"})
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "opgave", "project_id": projectID,
})
ts.do(t, "POST", "/api/v1/tasks/"+task["id"].(string)+"/comments",
map[string]any{"body": "sig noget"})
// The other member hears about it.
_, list := other.do(t, "GET", "/api/v1/notifications", nil)
notifications, _ := list["notifications"].([]any)
if len(notifications) == 0 {
t.Fatal("the other member was not notified about the comment")
}
if list["unread"] == float64(0) {
t.Error("the notification is not counted as unread")
}
// The author is not told what they just wrote.
_, own := ts.do(t, "GET", "/api/v1/notifications", nil)
if got, _ := own["notifications"].([]any); len(got) != 0 {
t.Errorf("the author was notified about their own comment: %v", got)
}
other.do(t, "POST", "/api/v1/notifications/read", nil)
_, after := other.do(t, "GET", "/api/v1/notifications", nil)
if after["unread"] != float64(0) {
t.Errorf("unread = %v after marking everything read", after["unread"])
}
}
// Quick add can put a task straight into a section.
//
// A section belongs to one project, so the name is resolved after the project is
// decided — and two projects may both have a "Produktion", which are different
// sections. A name that matches nothing is reported the way an unknown project is:
// the task is still created, and the caller is told which word did not land.
func TestQuickAddPutsATaskInASection(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Risteriet"})
projectID := project["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+projectID+"/sections",
map[string]any{"name": "Kunder/Ordrer"})
sectionID := section["id"].(string)
// The section name has a slash in it, which is also the sigil. The bare form is
// greedy, so the whole name comes across.
resp, task := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "Karpenhøj kaffe bestilling #Risteriet /Kunder/Ordrer"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("quick add: status %d, body %v", resp.StatusCode, task)
}
if task["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", task["section_id"], sectionID)
}
if task["content"] != "Karpenhøj kaffe bestilling" {
t.Errorf("content = %v — the sigils should be gone", task["content"])
}
// Case-insensitive, like the project sigil.
_, lower := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": `nye poser #Risteriet /"kunder/ordrer"`})
if lower["section_id"] != sectionID {
t.Errorf("a differently-cased name did not resolve: %v", lower["section_id"])
}
// A name that matches nothing still creates the task, and says so.
_, missing := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "noget andet #Risteriet /Findesikke"})
if missing["section_id"] != nil && missing["section_id"] != "" {
t.Errorf("an unknown section should leave the task unsectioned: %v", missing["section_id"])
}
if missing["unknown_section"] != "Findesikke" {
t.Errorf("unknown_section = %v, want Findesikke", missing["unknown_section"])
}
if missing["content"] != "noget andet" {
t.Errorf("content = %v", missing["content"])
}
// A section in another project is a different section, and must not be found.
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andet"})
ts.do(t, "POST", "/api/v1/projects/"+other["id"].(string)+"/sections",
map[string]any{"name": "Egen"})
_, crossed := ts.do(t, "POST", "/api/v1/tasks/quick-add",
map[string]any{"text": "forkert sted #Risteriet /Egen"})
if crossed["unknown_section"] != "Egen" {
t.Errorf("a section from another project was found: %v", crossed["section_id"])
}
}
// The field at the foot of a section passes the section it sits in, and a
// "#project" in the same line can move the task out from under it.
func TestQuickAddSectionIDIsCheckedAgainstTheProject(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, here := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Her"})
hereID := here["id"].(string)
_, section := ts.do(t, "POST", "/api/v1/projects/"+hereID+"/sections",
map[string]any{"name": "Produktion"})
sectionID := section["id"].(string)
// Standing in the section is enough; no sigil needed.
_, plain := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen", "project_id": hereID, "section_id": sectionID,
})
if plain["section_id"] != sectionID {
t.Errorf("section_id = %v, want %v", plain["section_id"], sectionID)
}
// A "#project" that moves the task must not drag the section id with it: that
// section belongs to the project being left behind.
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsted"})
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
"text": "male gavlen #Andetsted", "project_id": hereID, "section_id": sectionID,
})
if moved["section_id"] != nil && moved["section_id"] != "" {
t.Errorf("the section came along to another project: %v", moved["section_id"])
}
if moved["content"] != "male gavlen" {
t.Errorf("content = %v", moved["content"])
}
}
// A word inside a word, on the task side.
//
// The notes search grew this first — a note holding `keepInventory` could not be
// found by typing "keep inventory" — and a task is the same sentence with a
// different table behind it. `SpeedAdmin` is one token, so "speed admin" asks for
// two words that are nowhere.
//
// Having it in one of the two searches would be worse than having it in neither:
// "it finds it in my notes but not in my tasks" is the next report.
func TestATaskIsFoundByAWordInsideAWord(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
"content": "Opret kodeord til Kulturskolen (SpeedAdmin)",
})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Køb kaffe"})
for _, tc := range []struct {
query string
want int
}{
{"speed admin", 1},
{"admin", 1},
{"speedadmin", 1},
{"kulturskolen", 1},
{"vandhane", 0},
} {
resp, body := ts.do(t, "GET", "/api/v1/search?q="+url.QueryEscape(tc.query), nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%q: status %d", tc.query, resp.StatusCode)
}
tasks, _ := body["tasks"].([]any)
if len(tasks) != tc.want {
t.Errorf("%q found %d tasks, want %d", tc.query, len(tasks), tc.want)
}
}
}
// A snoozed task sinks to the bottom of the list and greys until its time; waking
// it puts it back. The list order is what the row's dimming rides on.
func TestSnoozeSinksAndWakes(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Alfa"})
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "Bravo"})
aID, _ := a["id"].(string)
// Snooze Alfa two days out.
future := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": future})
if resp.StatusCode != http.StatusOK {
t.Fatalf("snooze: %d %v", resp.StatusCode, body)
}
if body["snoozed_until"] == "" || body["snoozed_until"] == nil {
t.Error("the response does not carry snoozed_until")
}
// Alfa now sorts after Bravo, though it was created first.
order := func() []string {
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
var out []string
for _, raw := range list["tasks"].([]any) {
out = append(out, raw.(map[string]any)["content"].(string))
}
return out
}
got := order()
if len(got) != 2 || got[0] != "Bravo" || got[1] != "Alfa" {
t.Errorf("snoozed task did not sink: order = %v", got)
}
// Wake it, and it comes back above Bravo.
resp, body = ts.do(t, "POST", "/api/v1/tasks/"+aID+"/snooze", map[string]any{"until": ""})
if resp.StatusCode != http.StatusOK {
t.Fatalf("wake: %d %v", resp.StatusCode, body)
}
if s, _ := body["snoozed_until"].(string); s != "" {
t.Errorf("waking left snoozed_until set: %q", s)
}
if got := order(); got[0] != "Alfa" {
t.Errorf("woken task did not return: order = %v", got)
}
}