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