It was a heading over some rows: a name, a colour, and whether it was folded.
Enough to tidy a list, and not enough to be what people mean by it — "Arbejde" is
a body of work with context of its own, and documents that belong to all of it
rather than to any one project inside it.
`/gruppe/{id}` carries three things a heading cannot. The projects in it, each
with what is still open — open tasks, not all of them, because "12" beside a
project somebody finished last year is a number that means nothing. A description,
written in place and saved on blur, the same shape a task's description has. And
the files that govern the whole group: the contract, the drawing, the agreement
everything else rests on.
The name in the sidebar is a link to it and the chevron beside it still folds.
Two intentions, two targets — the single one they used to share could only ever
serve the smaller.
Migration 0012 rebuilds `attachments`, because its CHECK spelled out exactly which
parent columns may be set and SQLite cannot alter a CHECK. The rule is still
"exactly one parent", counted rather than written out as pairwise comparisons:
with three columns that is three clauses, with four it is six, and the version
that is wrong looks exactly like the one that is right.
A group's file is reachable by its owner and nobody else. A group belongs to one
person and is never shared, so `reachAttachment` asks about ownership rather than
a project role — both old callers resolved straight to a task, so a group's
attachment answered 404 for the person who uploaded it.
Three tests also stopped racing their own navigation. Creating a project navigates
and `goto` is a promise nobody was holding, so a quick add fired straight after
could land in the Inbox instead — intermittent, and it read as the quick add
having failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
352 lines
13 KiB
Go
352 lines
13 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
func TestAGroupHoldsProjectsAndSurvivesBeingEmptied(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
if resp.StatusCode != http.StatusCreated {
|
|
t.Fatalf("create group: %d %v", resp.StatusCode, group)
|
|
}
|
|
groupID := group["id"].(string)
|
|
if group["collapsed"] != false {
|
|
t.Errorf("a new group should be open: %v", group)
|
|
}
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
|
projectID := project["id"].(string)
|
|
|
|
resp, filed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID,
|
|
map[string]any{"group_id": groupID})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("file project: %d %v", resp.StatusCode, filed)
|
|
}
|
|
if filed["group_id"] != groupID {
|
|
t.Errorf("group_id = %v, want %s", filed["group_id"], groupID)
|
|
}
|
|
|
|
// The listing is what the sidebar reads, so the filing has to survive it.
|
|
if got := groupOf(t, ts, projectID); got != groupID {
|
|
t.Errorf("group_id in the list = %q, want %s", got, groupID)
|
|
}
|
|
|
|
// An empty group is still a group. This is the whole reason it is a table and
|
|
// not a string repeated on every project row.
|
|
resp, _ = ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": ""})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("ungroup: status %d", resp.StatusCode)
|
|
}
|
|
if got := groupOf(t, ts, projectID); got != "" {
|
|
t.Errorf("group_id = %q after ungrouping, want empty", got)
|
|
}
|
|
|
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
|
if groups, _ := listed["groups"].([]any); len(groups) != 1 {
|
|
t.Fatalf("the emptied group is gone: %v", listed)
|
|
}
|
|
}
|
|
|
|
// An omitted group_id must not clear the field: that is the difference between a
|
|
// PATCH and a PUT, and renaming a project would otherwise tip it out of its group.
|
|
func TestRenamingAProjectLeavesItInItsGroup(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
|
|
|
_, renamed := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"name": "Bogholderi"})
|
|
if renamed["group_id"] != groupID {
|
|
t.Errorf("group_id = %v after a rename, want %s", renamed["group_id"], groupID)
|
|
}
|
|
}
|
|
|
|
// Deleting the heading is not a request to delete the work filed under it.
|
|
func TestDeletingAGroupKeepsItsProjects(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
|
projectID := project["id"].(string)
|
|
ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
|
|
|
resp, _ := ts.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil)
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("delete group: status %d", resp.StatusCode)
|
|
}
|
|
|
|
_, projects := ts.do(t, "GET", "/api/v1/projects", nil)
|
|
found := false
|
|
for _, raw := range projects["projects"].([]any) {
|
|
p := raw.(map[string]any)
|
|
if p["id"] != projectID {
|
|
continue
|
|
}
|
|
found = true
|
|
if p["group_id"] != nil && p["group_id"] != "" {
|
|
t.Errorf("the project still points at a group that is gone: %v", p["group_id"])
|
|
}
|
|
}
|
|
if !found {
|
|
t.Error("the project went with the group it was filed under")
|
|
}
|
|
}
|
|
|
|
// Folding is stored on the account rather than in the browser, so it has to come
|
|
// back from the server.
|
|
func TestFoldingAGroupIsRemembered(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
|
|
resp, folded := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
|
map[string]any{"collapsed": true})
|
|
if resp.StatusCode != http.StatusOK || folded["collapsed"] != true {
|
|
t.Fatalf("fold: %d %v", resp.StatusCode, folded)
|
|
}
|
|
|
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
|
first := listed["groups"].([]any)[0].(map[string]any)
|
|
if first["collapsed"] != true {
|
|
t.Errorf("collapsed = %v after re-reading, want true", first["collapsed"])
|
|
}
|
|
}
|
|
|
|
// Groups are per person: somebody else's is not yours to see, rename or delete,
|
|
// and a group that is not yours is not found rather than forbidden.
|
|
func TestGroupsArePerPerson(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
_, listed := other.do(t, "GET", "/api/v1/project-groups", nil)
|
|
if groups, _ := listed["groups"].([]any); len(groups) != 0 {
|
|
t.Errorf("somebody else's groups are visible: %v", groups)
|
|
}
|
|
if resp, _ := other.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
|
map[string]any{"name": "Mit"}); resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("renaming somebody else's group: status %d, want 404", resp.StatusCode)
|
|
}
|
|
if resp, _ := other.do(t, "DELETE", "/api/v1/project-groups/"+groupID, nil); resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("deleting somebody else's group: status %d, want 404", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
// A project cannot be filed into a group that is not the caller's, even though
|
|
// both the project and the group exist and both requests would be well-formed.
|
|
func TestAProjectCannotJoinSomebodyElsesGroup(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
_, group := other.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Deres"})
|
|
groupID := group["id"].(string)
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Mit"})
|
|
projectID := project["id"].(string)
|
|
|
|
resp, _ := ts.do(t, "PATCH", "/api/v1/projects/"+projectID, map[string]any{"group_id": groupID})
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("status %d, want 404 — a group you cannot see is not one you can file into",
|
|
resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestGroupsReorderAsAWholeList(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
ids := make([]string, 3)
|
|
for i, name := range []string{"En", "To", "Tre"} {
|
|
_, g := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": name})
|
|
ids[i] = g["id"].(string)
|
|
}
|
|
|
|
reversed := []string{ids[2], ids[1], ids[0]}
|
|
resp, _ := ts.do(t, "POST", "/api/v1/project-groups/reorder", map[string]any{"ids": reversed})
|
|
if resp.StatusCode != http.StatusNoContent {
|
|
t.Fatalf("reorder: status %d", resp.StatusCode)
|
|
}
|
|
|
|
_, listed := ts.do(t, "GET", "/api/v1/project-groups", nil)
|
|
got := listed["groups"].([]any)
|
|
for i, want := range reversed {
|
|
if got[i].(map[string]any)["id"] != want {
|
|
t.Fatalf("order = %v, want %v", got, reversed)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A name is required, the same as a project's. An unnamed heading is a heading
|
|
// nobody can tell apart from the next one.
|
|
func TestAGroupNeedsAName(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
resp, body := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": " "})
|
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Fatalf("status %d, want 422 — body %v", resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// groupOf reads a project's group back out of the listing the sidebar uses.
|
|
func groupOf(t *testing.T, ts *testServer, projectID string) string {
|
|
t.Helper()
|
|
_, body := ts.do(t, "GET", "/api/v1/projects", nil)
|
|
for _, raw := range body["projects"].([]any) {
|
|
p := raw.(map[string]any)
|
|
if p["id"] == projectID {
|
|
id, _ := p["group_id"].(string)
|
|
return id
|
|
}
|
|
}
|
|
t.Fatalf("project %s is not in the listing", projectID)
|
|
return ""
|
|
}
|
|
|
|
// Colour is a name from a closed set, on both a project and a group.
|
|
//
|
|
// Refused rather than stored: an unknown name would be written happily and then
|
|
// painted as the default, which looks like a colour that did not save.
|
|
func TestAColourMustBeOneFromThePalette(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
if group["color"] != "graphite" {
|
|
t.Errorf("a new group's colour = %v, want graphite", group["color"])
|
|
}
|
|
|
|
_, project := ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab"})
|
|
projectID := project["id"].(string)
|
|
|
|
for _, c := range []struct {
|
|
what, method, path string
|
|
}{
|
|
{"group", "PATCH", "/api/v1/project-groups/" + groupID},
|
|
{"project", "PATCH", "/api/v1/projects/" + projectID},
|
|
} {
|
|
resp, body := ts.do(t, c.method, c.path, map[string]any{"color": "teal"})
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("%s, a real colour: %d %v", c.what, resp.StatusCode, body)
|
|
}
|
|
if body["color"] != "teal" {
|
|
t.Errorf("%s colour = %v, want teal", c.what, body["color"])
|
|
}
|
|
|
|
resp, body = ts.do(t, c.method, c.path, map[string]any{"color": "#ff0000"})
|
|
if resp.StatusCode != http.StatusUnprocessableEntity {
|
|
t.Errorf("%s, a hex value: status %d, want 422 — body %v", c.what, resp.StatusCode, body)
|
|
}
|
|
}
|
|
|
|
// A rename does not disturb the colour, and a recolour does not disturb the
|
|
// name: both are PATCH fields on the same row, and the whole point of a PATCH
|
|
// is that the one you did not send is the one you did not mean.
|
|
_, renamed := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID,
|
|
map[string]any{"name": "Kontoret"})
|
|
if renamed["color"] != "teal" {
|
|
t.Errorf("colour = %v after a rename, want teal", renamed["color"])
|
|
}
|
|
if renamed["name"] != "Kontoret" {
|
|
t.Errorf("name = %v", renamed["name"])
|
|
}
|
|
}
|
|
|
|
// A group is somewhere you can go, not only a heading.
|
|
func TestAGroupIsAPageWithItsProjectsDescriptionAndFiles(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
|
|
_, first := ts.do(t, "POST", "/api/v1/projects", map[string]any{
|
|
"name": "Sæsonstart", "group_id": groupID,
|
|
})
|
|
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Regnskab", "group_id": groupID})
|
|
// And one outside it, which must not appear.
|
|
ts.do(t, "POST", "/api/v1/projects", map[string]any{"name": "Haven"})
|
|
|
|
// Two open, one closed — the count is what is left, not what has ever been.
|
|
ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "skriv oplæg", "project_id": first["id"].(string),
|
|
})
|
|
_, done := ts.do(t, "POST", "/api/v1/tasks", map[string]any{
|
|
"content": "book lokale", "project_id": first["id"].(string),
|
|
})
|
|
ts.do(t, "POST", "/api/v1/tasks/"+done["id"].(string)+"/complete", nil)
|
|
|
|
resp, page := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
|
if resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("get group: status %d, body %v", resp.StatusCode, page)
|
|
}
|
|
projects, _ := page["projects"].([]any)
|
|
if len(projects) != 2 {
|
|
t.Fatalf("the page lists %d projects, want 2", len(projects))
|
|
}
|
|
for _, raw := range projects {
|
|
p := raw.(map[string]any)
|
|
if p["name"] == "Haven" {
|
|
t.Error("a project outside the group is on its page")
|
|
}
|
|
if p["name"] == "Sæsonstart" && p["open_tasks"] != float64(1) {
|
|
t.Errorf("open_tasks = %v, want 1 — the closed one must not count", p["open_tasks"])
|
|
}
|
|
}
|
|
|
|
// The description, which is the part a heading cannot carry.
|
|
if resp, _ := ts.do(t, "PATCH", "/api/v1/project-groups/"+groupID, map[string]any{
|
|
"description": "Alt det, der har en faktura for enden.",
|
|
}); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("set description: status %d", resp.StatusCode)
|
|
}
|
|
_, again := ts.do(t, "GET", "/api/v1/project-groups/"+groupID, nil)
|
|
if g, _ := again["group"].(map[string]any); g["description"] != "Alt det, der har en faktura for enden." {
|
|
t.Errorf("description = %v", g["description"])
|
|
}
|
|
// And setting only the description must not lose the name or colour.
|
|
if g, _ := again["group"].(map[string]any); g["name"] != "Arbejde" {
|
|
t.Errorf("the name went with the description: %v", g["name"])
|
|
}
|
|
}
|
|
|
|
// A group belongs to one person and is never shared, so somebody else's group is
|
|
// not found — including its files.
|
|
func TestAGroupsPageAndFilesAreItsOwnersAlone(t *testing.T) {
|
|
ts := newTestServer(t)
|
|
ts.bootstrap(t)
|
|
other := ts.newUser(t, "anden@example.dk", "Anden")
|
|
|
|
_, group := ts.do(t, "POST", "/api/v1/project-groups", map[string]any{"name": "Arbejde"})
|
|
groupID := group["id"].(string)
|
|
|
|
for _, c := range []struct{ method, path string }{
|
|
{"GET", "/api/v1/project-groups/" + groupID},
|
|
{"PATCH", "/api/v1/project-groups/" + groupID},
|
|
{"POST", "/api/v1/project-groups/" + groupID + "/attachments"},
|
|
} {
|
|
resp, _ := other.do(t, c.method, c.path, map[string]any{"description": "min nu"})
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("%s %s as somebody else: status %d, want 404", c.method, c.path, resp.StatusCode)
|
|
}
|
|
}
|
|
}
|