`projects.color` has been in the schema since the first migration, has defaulted to 'graphite' on every row ever written, and nothing has ever read it. There was no palette, no name-to-colour mapping, and the dot in the sidebar was hardcoded to a border grey. So this is not "add a picker"; it is implementing colour for the first time. Names, not hex values. A hex in ten thousand rows is a decision nobody can revisit; a name is resolved when the interface paints and can be retuned for a new theme or a new display. It is also the difference between a database that stores a fact and one that stores a rendering. The set is closed and refused at the edge — an unknown name would be stored happily and painted as the default, which looks like a colour that did not save. One palette across all five themes, and this is the one place in app.css that is not redefined per theme. Everything else there is text, a ground or a border and has to be measured against what sits behind it. These are small solid marks beside a label that already carries the meaning: what they owe is to be told apart, and to be visible on both a near-black and a white ground, which is why all ten sit in the mid-tone band. It also keeps them out of the trap the themes are in — a colour added to four blocks and forgotten in the fifth looks finished and falls back silently. There is no fifth block to forget. The list is written down three times: Go for validation, colors.js for the picker, app.css for the tokens that paint. So a test reads the other two — the same small-scanner approach openapi_test.go takes to the spec — and fails if the server accepts a colour nothing can draw, or the picker offers one the server refuses. Groups get a colour of their own (migration 0006) and projects inside a group are indented. The indent is to the group's *name*, not to its dot: the eye follows the text, and lining the projects up under the dot leaves them looking a step short of where the heading starts. The drop line is indented with them, so a drop inside a group reads as landing in the group. The group's dot is the control as well as the preview — you press the thing you are about to change, as on the project page. That was not the first design: a third text action, "Farve", sat beside Omdøb and Slet, and those carry `opacity: 0` rather than `display: none`, so they take their width whether or not you can see them. A group called "Arbejde" rendered as "ARBEJD". Moving the control onto the dot gave the name its room back. The smoke test measures the indent at the content edge rather than the element's, because the indent is padding and the box does not move — which is how the first version of that assertion passed 12 against 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
272 lines
9.6 KiB
Go
272 lines
9.6 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"])
|
|
}
|
|
}
|