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