Release / Build and publish image (push) Failing after 10m2s
Two more things the database has been recording and the interface has
never shown.
**Who a task is waiting on, on the row.** Only when it is not you: your own
list would otherwise carry your own face on every line, which is a column
of one initial telling you nothing. The point of showing an assignee is
that it is somebody else.
That needed names, and a task carries only an `assignee_id`. `GET /people`
is the primitive: everybody you share a project with, plus yourself,
fetched once per session. Not per row, and not per project either — a list
would have to know which project each task belongs to before it could ask
for that project's members. Deliberately *not* the instance's user list,
which is `/users` and administrators only: a task list has no business
enumerating everybody with an account here.
It sits first in the meta line, because who changes what the rest of the
line means — a date on somebody else's task is their deadline, not yours.
**And the activity log.** Nineteen kinds of entry have been written since
the beginning — every project, section, member, task and comment event —
and `GET /projects/{id}/activity` has answered honestly the whole time with
nothing on the other end. There is a Historik panel on the project now,
loaded when it is opened rather than with the page, and the event keys
become Danish in exactly one place. An unknown key falls through to itself
rather than being hidden: a build that records something this table has not
learned should still show that it happened.
One test assertion had to change, and it is worth saying why. It matched
"males inden fredag" against a row whose title is "males inden" — quick add
read "fredag" as the due date — so it was really matching the title
concatenated with the metadata beside it. Putting the assignee between them
broke it. The assertion now names the task's own text.
Co-Authored-By: Claude Opus 5 <[email protected]>
203 lines
6.9 KiB
Go
203 lines
6.9 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// A label is how you find a task whose wording you have forgotten. Search
|
|
// covered the text only, so "regnskab" found nothing even with the task
|
|
// labelled @regnskab in front of you.
|
|
func TestSearchFindsTasksByLabel(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
|
map[string]any{"text": "ring til revisoren @regnskab"})
|
|
ts.do(t, "POST", "/api/v1/tasks/quick-add",
|
|
map[string]any{"text": "køb kaffe"})
|
|
|
|
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
|
tasks, _ := body["tasks"].([]any)
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("found %d tasks searching for a label, want 1: %v", len(tasks), body)
|
|
}
|
|
if got := tasks[0].(map[string]any)["content"]; got != "ring til revisoren" {
|
|
t.Errorf("found %v", got)
|
|
}
|
|
}
|
|
|
|
// The text search still works, and a label match does not swallow it.
|
|
func TestSearchStillFindsTasksByText(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
ts.do(t, "POST", "/api/v1/tasks/quick-add", map[string]any{"text": "køb grøn maling"})
|
|
|
|
// Diacritic folding still applies to the text side.
|
|
_, body := ts.do(t, "GET", "/api/v1/search?q=gron", nil)
|
|
if tasks, _ := body["tasks"].([]any); len(tasks) != 1 {
|
|
t.Errorf("searching \"gron\" found %d tasks, want 1", len(tasks))
|
|
}
|
|
}
|
|
|
|
// Somebody else's label must not pull their task into your results — the label
|
|
// join has to stay inside the visibility the outer query already applies.
|
|
func TestSearchByLabelStaysWithinWhatYouCanSee(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
other := ts.newUser(t, "[email protected]", "Anden")
|
|
other.do(t, "POST", "/api/v1/tasks/quick-add",
|
|
map[string]any{"text": "deres hemmelighed @regnskab"})
|
|
|
|
_, body := ts.do(t, "GET", "/api/v1/search?q=regnskab", nil)
|
|
if tasks, _ := body["tasks"].([]any); len(tasks) != 0 {
|
|
t.Errorf("found somebody else's task: %v", tasks)
|
|
}
|
|
}
|
|
|
|
// "Waiting on others" is the other half of an assignee filter, and the half that
|
|
// is easy to get backwards: what somebody has handed over, not what they have
|
|
// been handed.
|
|
func TestDelegatedListsWhatOtherPeopleAreSittingOn(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
ts.newUser(t, "[email protected]", "Anders")
|
|
|
|
anders, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
me, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
|
"email": "[email protected]", "role": "editor",
|
|
})
|
|
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "anders skriver rapporten", "project_id": projectID, "assignee_id": anders.ID,
|
|
})
|
|
// Mine, and unassigned: neither belongs in a view about other people.
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "jeg læser den", "project_id": projectID, "assignee_id": me.ID,
|
|
})
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "ingen har den", "project_id": projectID,
|
|
})
|
|
|
|
resp, body := ts.do(t, "GET", "/api/v1/delegated", nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("delegated: %d %v", resp.StatusCode, body)
|
|
}
|
|
|
|
people, _ := body["people"].([]any)
|
|
if len(people) != 1 {
|
|
t.Fatalf("want one person, got %v", people)
|
|
}
|
|
person := people[0].(map[string]any)
|
|
if person["name"] != "Anders" {
|
|
t.Errorf("name = %v — the view has to carry the name, or the client has no way to ask for it",
|
|
person["name"])
|
|
}
|
|
if person["avatar_color"] == nil || person["avatar_color"] == "" {
|
|
t.Errorf("no avatar colour: %v", person)
|
|
}
|
|
|
|
tasks, _ := person["tasks"].([]any)
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("want one task, got %v", tasks)
|
|
}
|
|
if tasks[0].(map[string]any)["content"] != "anders skriver rapporten" {
|
|
t.Errorf("content = %v", tasks[0])
|
|
}
|
|
}
|
|
|
|
// The view is per person: Anders is not waiting on himself, and he cannot see
|
|
// what somebody has delegated in a project he is not in.
|
|
func TestDelegatedIsPerPersonAndRespectsAccess(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
anders := ts.newUser(t, "[email protected]", "Anders")
|
|
|
|
andersUser, err := ts.db.UserByEmail(t.Context(), "[email protected]")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// A project Anders is not a member of.
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Privat"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "POST", "/api/v1/projects/"+projectID+"/invites", map[string]any{
|
|
"email": "[email protected]", "role": "editor",
|
|
})
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "anders gør det", "project_id": projectID, "assignee_id": andersUser.ID,
|
|
})
|
|
|
|
// It is assigned to him, so it is not something he is waiting on.
|
|
_, body := anders.do(t, "GET", "/api/v1/delegated", nil)
|
|
if people, _ := body["people"].([]any); len(people) != 0 {
|
|
t.Errorf("Anders is waiting on himself: %v", people)
|
|
}
|
|
|
|
// And somebody with no access to the project sees none of it.
|
|
other := ts.newUser(t, "[email protected]", "Tredje")
|
|
_, third := other.do(t, "GET", "/api/v1/delegated", nil)
|
|
if people, _ := third["people"].([]any); len(people) != 0 {
|
|
t.Errorf("a task in a project they cannot see: %v", people)
|
|
}
|
|
}
|
|
|
|
// /people is what puts a name on an assignee id. It must be the people you share
|
|
// work with — not the instance's user list, which is the administrator's page.
|
|
func TestPeopleIsWhoYouShareWorkWith(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
colleague := ts.newUser(t, "[email protected]", "Anders")
|
|
ts.newUser(t, "[email protected]", "Fremmed")
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Delt"})
|
|
ts.do(t, "POST", "/api/v1/projects/"+project["id"].(string)+"/invites", map[string]any{
|
|
"email": "[email protected]", "role": "editor",
|
|
})
|
|
|
|
_, body := ts.do(t, "GET", "/api/v1/people", nil)
|
|
names := map[string]bool{}
|
|
for _, raw := range body["people"].([]any) {
|
|
p := raw.(map[string]any)
|
|
names[p["name"].(string)] = true
|
|
if p["avatar_color"] == "" {
|
|
t.Errorf("no avatar colour for %v — the row cannot draw a face", p["name"])
|
|
}
|
|
}
|
|
|
|
if !names["Kristian"] {
|
|
t.Error("yourself is missing, so a client cannot tell 'me' from 'somebody else' against one list")
|
|
}
|
|
if !names["Anders"] {
|
|
t.Error("somebody sharing a project is missing")
|
|
}
|
|
if names["Fremmed"] {
|
|
t.Error("an account with no project in common is listed — this is not the user directory")
|
|
}
|
|
|
|
// And it is symmetric: the person invited sees the person who invited them.
|
|
_, theirs := colleague.do(t, "GET", "/api/v1/people", nil)
|
|
seen := map[string]bool{}
|
|
for _, raw := range theirs["people"].([]any) {
|
|
seen[raw.(map[string]any)["name"].(string)] = true
|
|
}
|
|
if !seen["Kristian"] || !seen["Anders"] {
|
|
t.Errorf("the member cannot see who they share with: %v", seen)
|
|
}
|
|
if seen["Fremmed"] {
|
|
t.Errorf("a stranger is listed: %v", seen)
|
|
}
|
|
}
|