Files
verdande/internal/httpapi/tasks_test.go
T
kristianwindandClaude Opus 5 9e3f20b050 feat(tasks): snooze — park a task at the bottom until a time
Snooze puts a task out of the way without finishing it or filing it: it sinks to
the bottom of its list and greys, and comes back on its own when its time passes.
No job wakes it — "snoozed" is just snoozed_until being still in the future, read
against the clock each time the list is ordered.

- `snoozed_until` on tasks (migration 0031); the list order sinks a still-snoozed
  task below everything, and it rejoins its place the moment the time passes.
- POST /tasks/{id}/snooze {until} — an RFC3339 time to snooze to, or empty to wake.
  Nothing else about the task moves; a snooze is about when you want to see it, not
  when it is due.
- The task drawer gains quick picks (this evening / tomorrow / next week) and a
  custom time, and a Wake now once it is snoozed. The row dims and carries a
  "snoozed until X" mark, and the list keeps snoozed tasks at the foot — the client
  sorts the same way the server does, so a drag-sorted list does not jump on reload.

Tests: snooze sinks a task below one made after it and waking returns it (Go); and
from the drawer the row greys, shows the mark, and un-greys on wake (e2e). Full
suites green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-25 10:59:34 +02:00

1867 lines
63 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(), "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")
}
}
// --- 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, "anden@example.dk", "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, "anden@example.dk", "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, "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": "opgave", "project_id": projectID,
})
taskID := task["id"].(string)
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites",
map[string]any{"email": "viewer@example.dk", "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, "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"})
_, 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, "anden@example.dk", "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": "anden@example.dk", "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)
}
}