Files
brain/internal/http/kanban_test.go
T
Andrew S Erwin b9721ff13f
release gate / check (push) Failing after 8m35s
initial commit on Federted servers
2026-10-19 16:23:55 -05:00

124 lines
4.8 KiB
Go

package http
import "testing"
// Kanban ordering is persisted, not just a client-side illusion: a card move
// normalizes the target column, keeps legacy status-only cards in place, and
// still honors the bookmark concurrency version (review CR-04).
func TestKanbanCardMovePersistsOrdering(t *testing.T) {
srv := testServer(t)
alice := newAPIUser(t, srv, "kanban-order@computer.test")
cols := alice.getJSON("/api/kanban/columns", 200)
if len(cols) < 2 {
t.Fatalf("expected default columns, got %d", len(cols))
}
inbox := cols[0]["id"].(string)
todo := cols[1]["id"].(string)
create := func(title, column string) map[string]any {
return alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/" + title,
"title": title,
"kanbanColumnId": column,
}, 201)
}
first := create("first", inbox)
second := create("second", inbox)
third := create("third", inbox)
// New cards start newest-first (updatedAt desc), so establish the known
// first/second/third order before asserting a reorder. Third stays at its
// original version for the move below.
for _, step := range []struct {
id string
position int
}{{first["id"].(string), 0}, {second["id"].(string), 1}} {
cur := alice.request("GET", "/api/bookmarks/"+step.id, nil, 200)
alice.request("PUT", "/api/kanban/cards/"+step.id, map[string]any{
"columnId": inbox, "position": step.position, "version": cur["version"],
}, 200)
}
// Move the newest card to the top of its own column.
moved := alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 200)
if moved["kanbanPosition"] != float64(0) || moved["version"] != float64(1) {
t.Fatalf("move did not persist position/version: %v", moved)
}
if moved["kanbanColumnId"] != inbox {
t.Fatalf("move lost column: %v", moved)
}
board := func(column string) []map[string]any {
t.Helper()
out := []map[string]any{}
for _, row := range alice.getJSON("/api/bookmarks", 200) {
if row["kanbanColumnId"] == column {
out = append(out, row)
}
}
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j]["kanbanPosition"].(float64) < out[j-1]["kanbanPosition"].(float64); j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
order := board(inbox)
if len(order) != 3 {
t.Fatalf("expected 3 cards, got %d", len(order))
}
for i, want := range []string{third["id"].(string), first["id"].(string), second["id"].(string)} {
if order[i]["id"] != want {
t.Fatalf("position %d: got %v, want %s", i, order[i]["id"], want)
}
if order[i]["kanbanPosition"] != float64(i) {
t.Fatalf("card %s position = %v, want %d", order[i]["id"], order[i]["kanbanPosition"], i)
}
}
// Missing/stale versions are rejected without changing order.
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": inbox, "position": 0,
}, 400)
alice.request("PUT", "/api/kanban/cards/"+third["id"].(string), map[string]any{
"columnId": inbox, "position": 0, "version": 0,
}, 409)
// Cross-column moves also normalize the target and leave the source intact.
secondNow := alice.request("GET", "/api/bookmarks/"+second["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+second["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": secondNow["version"],
}, 200)
if got := board(todo); len(got) != 1 || got[0]["id"] != second["id"] {
t.Fatalf("cross-column move failed: %v", got)
}
if got := board(inbox); len(got) != 2 || got[0]["id"] != third["id"] || got[1]["id"] != first["id"] {
t.Fatalf("source column order changed unexpectedly: %v", got)
}
// A legacy status-only card joins the target column's explicit ordering.
legacy := alice.request("POST", "/api/bookmarks", map[string]any{
"url": "https://example.org/legacy", "title": "legacy", "kanbanStatus": "TODO",
}, 201)
alice.request("PUT", "/api/kanban/cards/"+legacy["id"].(string), map[string]any{
"columnId": todo, "position": 0, "version": 0,
}, 200)
if got := board(todo); len(got) != 2 || got[0]["id"] != legacy["id"] || got[1]["id"] != second["id"] {
t.Fatalf("legacy card did not join explicit ordering: %v", got)
}
if got := alice.request("GET", "/api/bookmarks/"+legacy["id"].(string), nil, 200); got["kanbanColumnId"] != todo {
t.Fatalf("legacy card did not gain explicit membership: %v", got)
}
// Foreign columns are never accepted.
bob := newAPIUser(t, srv, "kanban-other@computer.test")
bobCols := bob.getJSON("/api/kanban/columns", 200)
foreign := bobCols[0]["id"].(string)
firstNow := alice.request("GET", "/api/bookmarks/"+first["id"].(string), nil, 200)
alice.request("PUT", "/api/kanban/cards/"+first["id"].(string), map[string]any{
"columnId": foreign, "position": 0, "version": firstNow["version"],
}, 400)
}