To halvdele af det samme: at en opgave med et klokkeslæt også kan få en
alarm, uden at man går en omvej hver gang.
Påmindelser har virket længe og skulle sættes én ad gangen. En opgave
dikteret som "i morgen kl 9" fik en tid og INGEN alarm — `CreateReminder`
blev kun kaldt fra sit eget endepunkt, så intet skabte en påmindelse af sig
selv. Derefter skulle man åbne opgaven, finde Påmindelser og vælge et
klokkeslæt, og den omvej er længere end at skrive opgaven.
SÆTNINGEN
Hurtig tilføjelse forstår nu alarmen:
ring til tandlægen i morgen kl 9 #hjem mind mig en time før
Formerne er `mind mig` / `påmind` / `remind me` / `med alarm`, efterfulgt af
en varighed (`en time før`, `10 min før`, `et kvarter før`, `en halv time
før`, `dagen før`, `to dage før`) eller et klokkeslæt (`mind mig kl 8`).
Bar "mind mig" betyder på selve tidspunktet. Dansk og engelsk i samme
linje, som resten af parseren.
Det gælder uanset hvor stemmen kommer ind: tastaturets diktering, en
genvej, eller noget tredje ender som tekst gennem den samme parser. Derfor
er syntaksen forudsætningen for dem alle frem for én af dem.
Trinnet ligger FØR klokke og dato, og den rækkefølge er hele tricket: "mind
mig en time før" har en varighed i sig og "mind mig kl 8" har en klokke, og
begge læst af tidsparseren først bliver opgavens egen forfaldstid i stedet
for dens alarm.
STANDARDEN
En indstilling under Notifikationer giver opgaver med både dato og
klokkeslæt en alarm uden at blive bedt om det, med et valgfrit varsel.
Den rammer alle tre veje, en opgave kan få et klokkeslæt — oprettet,
redigeret og hurtig-tilføjet. Redigeringsvejen er den halvdel, løftet om
"alle opgaver med en dato og tid" ellers taber: en opgave skrevet nu og
givet en tid i eftermiddag er den samme opgave. CalDAV er bevidst udenfor:
en opgave fra Apple Påmindelser bærer sin egen alarm, og to alarmer for én
opgave er værre end ingen.
Slået FRA for alle, der findes i dag. At tænde den i en migrering ville
give hver eksisterende bruger alarmer, de aldrig har bedt om, på opgaver de
har liggende — og det her er software, andre selv driver.
Og standarden skabte et behov for en udvej, så den fik en: "ingen alarm" i
linjen springer en enkelt opgave over, mens man skriver den, frem for at
oprette den, åbne den og slette alarmen der lige blev lavet. En udtrykkelig
alarm slår også standarden — den lægger sig ikke oven på.
TRE TING LÆSNINGEN FANGEDE
* Fortegnet. Lageret LÆGGER forskydningen til forfaldstiden
(`due_datetime + offset_min * 60`), så "en time før" er -60. Gættet
havde givet en alarm en time for sent, og det er den ene retning en
påmindelse ikke må tage fejl i. Samme konvention hele vejen —
indstillingen, parseren og kolonnen — for to fortegnsregler for det
samme begreb viser sig kun som en alarm på et forkert tidspunkt.
* Den stille døde alarm. `DueReminders` vælger forskydningsformen kun
`AND t.due_datetime IS NOT NULL`. Så "mind mig en time før" på en opgave
med en dag men ingen klokketid ville have lagt en række, der aldrig
fyrer. Nu bliver opgaven oprettet, alarmen ikke, og det bliver sagt.
Det er den ene slags fejl en påmindelse ikke må have, fordi intet
bagefter afslører den: der står ingen række at savne.
* "-60 min." på skærmen. TaskDetail viste forskydninger råt. Før nu kunne
de kun laves gennem API'et, så grenen stod reelt urørt — dette er det
første, der bruger den i praksis. Den skriver nu "1 time før".
PRØVET
Prøverne spørger `DueReminders` frem for om rækken findes: en række med
forkert fortegn findes lige så overbevisende og ringer en time for sent. Et
minut før den er forfalden må alarmen ikke være i listen, et minut efter
skal den.
Hver plantet fejl er kontrolleret til at falde i præcis den prøve, der
findes for den — fortegnet vendt, ordgrænsen fjernet, trinnet flyttet efter
dato, umulig klokke accepteret, dobbelt-vagten slået fra, "ingen alarm"
ignoreret, positivt tal accepteret, råt fortegn vist.
Og prøvens eget navn er en rettelse: de første fejlede i hele suiten men
bestod isoleret. Suiten kører med én arbejder mod ÉN database, og "ring til
tandlægen" findes fire steder i filen, så `.first()` ramte en anden prøves
opgave uden alarm.
Go-suiten grøn. E2E: 131 bestod, 0 netafbrydelser.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2470 lines
83 KiB
Go
2470 lines
83 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"archive/zip"
|
|
"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)
|
|
}
|
|
}
|
|
|
|
// The horizon is the list's, and it is asked for per request: seven days is the
|
|
// default, thirty is what Kommende offers for "when does this month get busy", and
|
|
// anything past the cap is trimmed rather than refused — a client that asks for a
|
|
// year gets a month, not a 400.
|
|
func TestUpcomingHorizon(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, out := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "om tyve dage",
|
|
"due_date": userDate(t, 20),
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create: %d %v", resp.StatusCode, out)
|
|
}
|
|
|
|
count := func(query string) (int, int) {
|
|
t.Helper()
|
|
_, view := ts.do(t, "GET", "/api/v1/upcoming"+query, nil)
|
|
days, _ := view["days"].([]any)
|
|
var tasks int
|
|
for _, d := range days {
|
|
day, _ := d.(map[string]any)
|
|
list, _ := day["tasks"].([]any)
|
|
tasks += len(list)
|
|
}
|
|
return len(days), tasks
|
|
}
|
|
|
|
if n, found := count(""); n != 7 || found != 0 {
|
|
t.Errorf("default horizon returned %d days and %d tasks, want 7 and 0", n, found)
|
|
}
|
|
if n, found := count("?days=30"); n != 30 || found != 1 {
|
|
t.Errorf("days=30 returned %d days and %d tasks, want 30 and 1", n, found)
|
|
}
|
|
if n, _ := count("?days=365"); n != 31 {
|
|
t.Errorf("days=365 returned %d days, want it capped at 31", n)
|
|
}
|
|
if n, _ := count("?days=nonsense"); n != 7 {
|
|
t.Errorf("an unreadable days returned %d days, want the default 7", n)
|
|
}
|
|
}
|
|
|
|
func TestSearchFindsAcrossProjects(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "køb grøn maling"})
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "ring til Anders"})
|
|
|
|
// Typed without the Danish letter, as somebody on the wrong keyboard would.
|
|
resp, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("search: %d %v", resp.StatusCode, body)
|
|
}
|
|
tasks, _ := body["tasks"].([]any)
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("search for 'gron' returned %d tasks, want 1", len(tasks))
|
|
}
|
|
if tasks[0].(map[string]any)["content"] != "køb grøn maling" {
|
|
t.Errorf("wrong task: %v", tasks[0])
|
|
}
|
|
}
|
|
|
|
func TestDragAndDropOrdering(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
ids := make([]string, 3)
|
|
for i, name := range []string{"første", "anden", "tredje"} {
|
|
_, body := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": name})
|
|
ids[i] = body["id"].(string)
|
|
}
|
|
|
|
// Drag the third task to the top: before the first, after nothing.
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+ids[2]+"/move", map[string]any{
|
|
"before_id": ids[0],
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("move: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
|
tasks, _ := list["tasks"].([]any)
|
|
if len(tasks) != 3 {
|
|
t.Fatalf("got %d tasks", len(tasks))
|
|
}
|
|
if got := tasks[0].(map[string]any)["content"]; got != "tredje" {
|
|
t.Errorf("first task is %v, want tredje", got)
|
|
}
|
|
}
|
|
|
|
// Repeatedly dropping into the same gap halves it each time. Eventually the
|
|
// midpoint of two neighbours equals one of them, and the list has to be renumbered
|
|
// rather than silently stop reordering.
|
|
func TestOrderingSurvivesManyDropsIntoTheSameGap(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, a := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "a"})
|
|
_, b := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "b"})
|
|
topID, bottomID := a["id"].(string), b["id"].(string)
|
|
|
|
var lastID string
|
|
for i := 0; i < 80; i++ {
|
|
_, task := ts.do(t, "POST", "/api/v1/tasks", map[string]any{"content": "mellem"})
|
|
id := task["id"].(string)
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/"+id+"/move", map[string]any{
|
|
"after_id": topID, "before_id": bottomID,
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("move %d: %d %v", i, resp.StatusCode, body)
|
|
}
|
|
lastID = id
|
|
}
|
|
|
|
// The most recent insert must still sit between the two anchors.
|
|
_, list := ts.do(t, "GET", "/api/v1/tasks", nil)
|
|
tasks, _ := list["tasks"].([]any)
|
|
var posLast, posTop, posBottom = -1, -1, -1
|
|
for i, raw := range tasks {
|
|
switch raw.(map[string]any)["id"] {
|
|
case lastID:
|
|
posLast = i
|
|
case topID:
|
|
posTop = i
|
|
case bottomID:
|
|
posBottom = i
|
|
}
|
|
}
|
|
if posTop < 0 || posBottom < 0 || posLast < 0 {
|
|
t.Fatal("a task went missing")
|
|
}
|
|
if !(posTop < posLast && posLast < posBottom) {
|
|
t.Errorf("ordering broke down: top=%d last=%d bottom=%d", posTop, posLast, posBottom)
|
|
}
|
|
}
|
|
|
|
// --- permissions, through the API -------------------------------------------------
|
|
|
|
// The promise of sharing: a viewer reads and cannot write.
|
|
func TestViewerCannotMutateThroughTheAPI(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
viewer := ts.newUser(t, "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.
|
|
|
|
// TestQuickAddSubTask covers the parent a pasted list's indented lines use.
|
|
//
|
|
// The line still goes through the same parser as a top-level one — a sub-task
|
|
// with a date is an ordinary thing to paste, and a second, dumber creation path
|
|
// for indented lines would be a second place for "i morgen" to mean something
|
|
// different.
|
|
func TestQuickAddSubTask(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, parent := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "Flytte garagen",
|
|
})
|
|
parentID, _ := parent["id"].(string)
|
|
if parentID == "" {
|
|
t.Fatalf("no parent: %v", parent)
|
|
}
|
|
|
|
_, child := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "Rydde hylderne p1", "parent_id": parentID,
|
|
})
|
|
if got := child["parent_id"]; got != parentID {
|
|
t.Errorf("parent_id = %v, want %v", got, parentID)
|
|
}
|
|
// The line was parsed, not stored raw: the priority marker is gone from the
|
|
// text and present as a priority.
|
|
if got := child["content"]; got != "Rydde hylderne" {
|
|
t.Errorf("content = %v, want the line without its marker", got)
|
|
}
|
|
if got := child["priority"]; got != float64(1) {
|
|
t.Errorf("priority = %v, want 1", got)
|
|
}
|
|
|
|
t.Run("a sub-task cannot be put in another project", func(t *testing.T) {
|
|
_, other := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Andetsteds"})
|
|
otherID, _ := other["id"].(string)
|
|
|
|
_, moved := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "Hente nøglen #Andetsteds", "parent_id": parentID,
|
|
})
|
|
if got := moved["project_id"]; got == otherID {
|
|
t.Errorf("the sub-task left its parent's project")
|
|
}
|
|
// And it is said out loud rather than the word just disappearing from the
|
|
// title — the failure this reports is exactly the one that used to be silent.
|
|
if got := moved["unknown_project"]; got != "Andetsteds" {
|
|
t.Errorf("unknown_project = %v, want the name that could not be honoured", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// raw fetches a download — a zip or a calendar — as bytes.
|
|
//
|
|
// `do` decodes JSON, which these two are not: it would report an empty body and a
|
|
// parse error where the interesting thing is the bytes themselves.
|
|
func (ts *testServer) raw(t *testing.T, path string) ([]byte, string) {
|
|
t.Helper()
|
|
req, _ := http.NewRequest("GET", ts.URL+path, 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("%s: status %d", path, resp.StatusCode)
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return body, resp.Header.Get("Content-Type")
|
|
}
|
|
|
|
// TestExportEverythingAtOnce covers the two whole-account exports.
|
|
//
|
|
// The per-project links worked and were unusable at any size: fifteen projects
|
|
// meant fifteen downloads to move your own tasks somewhere else. These two are
|
|
// the same data without the fifteen clicks.
|
|
//
|
|
// Asserted on the CONTENTS rather than on the status: a zip with no entries and a
|
|
// calendar with no VTODOs are both 200 with a plausible Content-Type, and that is
|
|
// exactly the shape an export bug takes.
|
|
func TestExportEverythingAtOnce(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
// Two projects, so the zip has to hold more than the one that happens to be
|
|
// first — a loop that exports only the last project passes a one-project test.
|
|
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Firma"})
|
|
_, second := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Hjem"})
|
|
firstID, _ := first["id"].(string)
|
|
secondID, _ := second["id"].(string)
|
|
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "Betal moms", "project_id": firstID, "due_date": "2026-11-03",
|
|
})
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "Skifte daek", "project_id": secondID,
|
|
})
|
|
|
|
t.Run("csv zip holds one file per project", func(t *testing.T) {
|
|
body, ct := ts.raw(t, "/api/v1/export/projects.zip")
|
|
if ct != "application/zip" {
|
|
t.Fatalf("content-type %q", ct)
|
|
}
|
|
zr, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
|
|
if err != nil {
|
|
t.Fatalf("not a zip: %v", err)
|
|
}
|
|
|
|
found := map[string]string{}
|
|
for _, f := range zr.File {
|
|
rc, err := f.Open()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
text, _ := io.ReadAll(rc)
|
|
rc.Close()
|
|
found[f.Name] = string(text)
|
|
}
|
|
if len(found) < 2 {
|
|
t.Fatalf("want a file per project, got %d: %v", len(found), found)
|
|
}
|
|
|
|
// The task has to be IN the file named after its own project. A zip with the
|
|
// right filenames and every task in the wrong one is the failure this is for.
|
|
var sawMoms, sawDaek bool
|
|
for name, text := range found {
|
|
// Filenames are lower-cased by ics.Filename, so the comparison is too —
|
|
// asserting on the stored capitalisation would be asserting on a detail
|
|
// the exporter is free to change.
|
|
lower := strings.ToLower(name)
|
|
if strings.Contains(text, "Betal moms") {
|
|
sawMoms = true
|
|
if !strings.Contains(lower, "firma") {
|
|
t.Errorf("Betal moms landed in %q, not in Firma", name)
|
|
}
|
|
}
|
|
if strings.Contains(text, "Skifte daek") {
|
|
sawDaek = true
|
|
if !strings.Contains(lower, "hjem") {
|
|
t.Errorf("Skifte daek landed in %q, not in Hjem", name)
|
|
}
|
|
}
|
|
}
|
|
if !sawMoms || !sawDaek {
|
|
t.Errorf("a task is missing from the zip: moms=%v daek=%v", sawMoms, sawDaek)
|
|
}
|
|
})
|
|
|
|
t.Run("ics holds every dated task across projects", func(t *testing.T) {
|
|
body, ct := ts.raw(t, "/api/v1/export/tasks.ics")
|
|
if !strings.HasPrefix(ct, "text/calendar") {
|
|
t.Fatalf("content-type %q", ct)
|
|
}
|
|
text := string(body)
|
|
if n := strings.Count(text, "BEGIN:VTODO"); n != 1 {
|
|
t.Fatalf("want the one dated task, got %d VTODOs:\n%s", n, text)
|
|
}
|
|
if !strings.Contains(text, "Betal moms") {
|
|
t.Errorf("the dated task is missing:\n%s", text)
|
|
}
|
|
// Undated tasks are deliberately left out — a VTODO with no date is a line
|
|
// most readers drop. Asserted so the omission stays a decision.
|
|
if strings.Contains(text, "Skifte daek") {
|
|
t.Errorf("an undated task was exported:\n%s", text)
|
|
}
|
|
})
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
// --- alarms written into the line ---------------------------------------------
|
|
|
|
// A dictated sentence sets its own alarm, and the alarm actually goes off.
|
|
//
|
|
// The assertion is on DueReminders rather than on a row existing, and the
|
|
// difference is the whole test: a reminder row with the offset the wrong way round
|
|
// exists just as convincingly and rings an hour LATE. So the question asked here is
|
|
// "would this fire, and when" — one minute before it is due it must not be in the
|
|
// list, one minute after it must.
|
|
func TestQuickAddSetsTheAlarmTheLineAskedFor(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "ring til tandlægen i morgen kl 9 mind mig en time før",
|
|
})
|
|
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, want %q", body["content"], "ring til tandlægen")
|
|
}
|
|
if body["reminder_not_set"] != nil {
|
|
t.Errorf("reminder_not_set = %v, want absent — the task has a clock", body["reminder_not_set"])
|
|
}
|
|
|
|
taskID, _ := body["id"].(string)
|
|
rems, err := ts.db.ListReminders(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Fatalf("got %d reminders, want 1", len(rems))
|
|
}
|
|
if rems[0].OffsetMin == nil {
|
|
t.Fatalf("reminder has no offset; it should not be absolute")
|
|
}
|
|
if *rems[0].OffsetMin != -60 {
|
|
t.Errorf("offset = %d, want -60 — the store ADDS this to the due time, so a "+
|
|
"positive number is an alarm an hour late", *rems[0].OffsetMin)
|
|
}
|
|
|
|
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
|
if err != nil {
|
|
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
|
}
|
|
|
|
early, err := ts.db.DueReminders(ctx, due.Add(-61*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("due reminders (early): %v", err)
|
|
}
|
|
if len(early) != 0 {
|
|
t.Errorf("61 minutes before it is due, %d reminders are already due", len(early))
|
|
}
|
|
|
|
ripe, err := ts.db.DueReminders(ctx, due.Add(-59*time.Minute))
|
|
if err != nil {
|
|
t.Fatalf("due reminders (ripe): %v", err)
|
|
}
|
|
if len(ripe) != 1 {
|
|
t.Fatalf("59 minutes before it is due, got %d reminders, want 1", len(ripe))
|
|
}
|
|
if ripe[0].TaskContent != "ring til tandlægen" {
|
|
t.Errorf("the reminder that came due is for %q", ripe[0].TaskContent)
|
|
}
|
|
}
|
|
|
|
// An alarm with nothing to count back from is refused out loud.
|
|
//
|
|
// "mind mig en time før" on a task with a day but no clock has no moment to be an
|
|
// hour before, and DueReminders selects the offset form only when the task has a
|
|
// due_datetime — so the row would sit there and never fire. The task is created and
|
|
// the caller is told, the same way an unknown #project is reported. An alarm that
|
|
// silently never rings is the one way a reminder must not fail.
|
|
func TestQuickAddSaysWhenTheAlarmCannotFire(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "køb mælk i morgen mind mig en time før",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
if body["content"] != "køb mælk" {
|
|
t.Errorf("content = %v, want %q", body["content"], "køb mælk")
|
|
}
|
|
if body["reminder_not_set"] == nil {
|
|
t.Errorf("reminder_not_set is absent; the alarm could not be set and nothing says so")
|
|
}
|
|
|
|
taskID, _ := body["id"].(string)
|
|
rems, err := ts.db.ListReminders(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Errorf("got %d reminders, want 0 — a reminder that can never fire is worse "+
|
|
"than none, because nothing afterwards reveals it", len(rems))
|
|
}
|
|
}
|
|
|
|
// A reminder given a clock of its own is kept as an absolute moment.
|
|
func TestQuickAddSetsAnAbsoluteAlarm(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "ring til tandlægen i morgen kl 9 mind mig kl 8",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
taskID, _ := body["id"].(string)
|
|
rems, err := ts.db.ListReminders(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Fatalf("got %d reminders, want 1", len(rems))
|
|
}
|
|
if rems[0].OffsetMin != nil {
|
|
t.Errorf("offset = %d, want none — a clock was written, so it is absolute", *rems[0].OffsetMin)
|
|
}
|
|
|
|
due, err := time.Parse(time.RFC3339, body["due_datetime"].(string))
|
|
if err != nil {
|
|
t.Fatalf("parse due_datetime %v: %v", body["due_datetime"], err)
|
|
}
|
|
// Due at nine, reminded at eight: one hour earlier, on the task's own day.
|
|
if want := due.Add(-time.Hour); !rems[0].RemindAt.Equal(want) {
|
|
t.Errorf("RemindAt = %v, want %v", rems[0].RemindAt, want)
|
|
}
|
|
}
|
|
|
|
// --- the account's standing alarm --------------------------------------------
|
|
|
|
// turnOnDefaultAlarm switches the account's standing alarm on, so many minutes
|
|
// before the due moment.
|
|
func turnOnDefaultAlarm(t *testing.T, ts *testServer, minutes int) {
|
|
t.Helper()
|
|
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": minutes})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("set default reminder: %d %v", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// With the setting on, a task that has a clock gets its alarm without being asked.
|
|
func TestDefaultAlarmAppliesToATaskWithAClock(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen kl 16",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Fatalf("got %d reminders, want 1", len(rems))
|
|
}
|
|
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
|
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
|
}
|
|
}
|
|
|
|
// And off is where every account starts. This is the control for the test above:
|
|
// without it, an alarm appearing for any reason at all reads as the setting working.
|
|
func TestNoDefaultAlarmUntilItIsTurnedOn(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen kl 16",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Errorf("got %d reminders on a fresh account, want 0 — turning a notification "+
|
|
"on for everybody is not a default anybody chose", len(rems))
|
|
}
|
|
}
|
|
|
|
// A task with a day but no clock gets nothing, because there is no moment to ring
|
|
// at: DueReminders selects the offset form only when due_datetime is set, so the
|
|
// row would be one that never fires.
|
|
func TestDefaultAlarmNeedsAClockNotJustADay(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Errorf("got %d reminders, want 0 — there is no clock to count back from", len(rems))
|
|
}
|
|
}
|
|
|
|
// The default does not stack on top of an alarm the line asked for.
|
|
func TestDefaultAlarmDoesNotStackOnAStatedOne(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen kl 16 mind mig en time før",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Fatalf("got %d reminders, want 1 — the stated alarm, and no default on top", len(rems))
|
|
}
|
|
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -60 {
|
|
t.Errorf("offset = %v, want -60 — what the line said, not the default", rems[0].OffsetMin)
|
|
}
|
|
}
|
|
|
|
// "ingen alarm" is the way out of the default while writing the task.
|
|
//
|
|
// Without it the only way to have a timed task that does not ring is to create it,
|
|
// open it, and delete the alarm that was just made — which is the omission a
|
|
// default quietly creates.
|
|
func TestNoAlarmInTheLineBeatsTheDefault(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen kl 16 ingen alarm",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
if body["content"] != "hente cyklen" {
|
|
t.Errorf("content = %v, want %q — the refusal should leave the title", body["content"], "hente cyklen")
|
|
}
|
|
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Errorf("got %d reminders, want 0 — the line said no alarm", len(rems))
|
|
}
|
|
}
|
|
|
|
// A clock added later gets the alarm too.
|
|
//
|
|
// This is the half a promise about "every task with a date and time" loses if the
|
|
// default is only applied where tasks are created: a task written now and given a
|
|
// time this afternoon is the same task.
|
|
func TestDefaultAlarmAppliesWhenTheClockComesLater(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
taskID := body["id"].(string)
|
|
|
|
rems, err := ts.db.ListReminders(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Fatalf("got %d reminders before there was a clock, want 0", len(rems))
|
|
}
|
|
|
|
resp, body = ts.do(t, "PATCH", "/api/v1/tasks/"+taskID, map[string]any{
|
|
"due_date": time.Now().AddDate(0, 0, 1).Format("2006-01-02"),
|
|
"due_time": "16:00",
|
|
})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("patch task: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
rems, err = ts.db.ListReminders(ctx, taskID)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Fatalf("got %d reminders after the clock was added, want 1", len(rems))
|
|
}
|
|
if rems[0].OffsetMin == nil || *rems[0].OffsetMin != -10 {
|
|
t.Errorf("offset = %v, want -10", rems[0].OffsetMin)
|
|
}
|
|
}
|
|
|
|
// A positive offset is refused. An alarm after the thing it warns about is not a
|
|
// reminder, and accepting one here would make the single mistake this value can
|
|
// make — a sign the wrong way round — look like a choice somebody made.
|
|
func TestDefaultAlarmRefusesAnOffsetAfterTheDueTime(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": 10})
|
|
if resp.StatusCode != http.StatusUnprocessableEntity && resp.StatusCode != http.StatusBadRequest {
|
|
t.Fatalf("set default reminder with +10: %d %v, want a refusal", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// Null turns it off again, and the alarms already made are left alone: a default is
|
|
// what happens to the NEXT task, not a statement about the ones already written.
|
|
func TestDefaultAlarmCanBeTurnedOffAgain(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ctx := t.Context()
|
|
turnOnDefaultAlarm(t, ts, -10)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente cyklen i morgen kl 16",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
kept := body["id"].(string)
|
|
|
|
resp, body = ts.do(t, "PUT", "/api/v1/auth/default-reminder", map[string]any{"minutes": nil})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("turn off: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
resp, body = ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{
|
|
"text": "hente bilen i morgen kl 17",
|
|
})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("quick add: %d %v", resp.StatusCode, body)
|
|
}
|
|
rems, err := ts.db.ListReminders(ctx, body["id"].(string))
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 0 {
|
|
t.Errorf("got %d reminders after turning the default off, want 0", len(rems))
|
|
}
|
|
|
|
rems, err = ts.db.ListReminders(ctx, kept)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rems) != 1 {
|
|
t.Errorf("the alarm made while it was on is gone; turning a default off is not " +
|
|
"a statement about the tasks already written")
|
|
}
|
|
}
|