Files
verdande/internal/httpapi/projectgroups_test.go
T
Kristian WindandClaude Opus 5 550f420c59 feat(sidebar): colours you can choose, and projects indented under their group
`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>
2026-08-17 21:50:20 +02:00

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"])
}
}