Files
kanban/backend/tools_test.go
T

400 lines
12 KiB
Go

package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// setupTestDB creates a temporary kanban DB for the duration of the test.
func setupTestDB(t *testing.T) *DB {
t.Helper()
dir := t.TempDir()
dbPath := filepath.Join(dir, "test_operations.db")
db, err := openDB(dbPath)
if err != nil {
t.Fatalf("openDB: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func mustJSON(t *testing.T, v any) json.RawMessage {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return b
}
func mustOK(t *testing.T, res ToolResult) {
t.Helper()
if !res.OK {
t.Fatalf("expected ok, got error: %s", res.Error)
}
}
func mustErr(t *testing.T, res ToolResult, contains string) {
t.Helper()
if res.OK {
t.Fatalf("expected error, got ok with result: %v", res.Result)
}
if contains != "" && !strings.Contains(res.Error, contains) {
t.Fatalf("error %q does not contain %q", res.Error, contains)
}
}
// --- list_board ---
func TestExecuteTool_ListBoard_Empty(t *testing.T) {
db := setupTestDB(t)
res := executeTool(db, "list_board", json.RawMessage(`{}`))
mustOK(t, res)
board, ok := res.Result.(map[string]any)
if !ok {
t.Fatalf("expected map[string]any, got %T", res.Result)
}
cols := board["columns"].([]Column)
cards := board["cards"].([]Card)
if len(cols) != 0 || len(cards) != 0 {
t.Fatalf("expected empty board, got %d cols %d cards", len(cols), len(cards))
}
}
// --- create_column / rename_column / delete_column / reorder_columns ---
func TestExecuteTool_CreateColumn(t *testing.T) {
db := setupTestDB(t)
res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Backlog"}))
mustOK(t, res)
col := res.Result.(*Column)
if col.Name != "Backlog" || col.Position != 0 || col.ID == "" {
t.Fatalf("unexpected column: %+v", col)
}
}
func TestExecuteTool_CreateColumn_EmptyName(t *testing.T) {
db := setupTestDB(t)
res := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": " "}))
mustErr(t, res, "name required")
}
func TestExecuteTool_UpdateColumn_Name(t *testing.T) {
db := setupTestDB(t)
created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"}))
col := created.Result.(*Column)
res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"}))
mustOK(t, res)
cols, _ := db.ListColumns()
if cols[0].Name != "New" {
t.Fatalf("rename failed: %s", cols[0].Name)
}
}
func TestExecuteTool_UpdateColumn_LocationAndWidth(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
loc := "sidebar"
width := 450
res := executeTool(db, "update_column", mustJSON(t, map[string]any{"id": col.ID, "location": loc, "width": width}))
mustOK(t, res)
cols, _ := db.ListColumns()
if cols[0].Location != "sidebar" || cols[0].Width != 450 {
t.Fatalf("update failed: %+v", cols[0])
}
}
func TestExecuteTool_UpdateColumn_NoFields(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
res := executeTool(db, "update_column", mustJSON(t, map[string]string{"id": col.ID}))
mustErr(t, res, "at least one")
}
func TestExecuteTool_RenameColumn_AliasStillWorks(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Old"})).Result.(*Column)
res := executeTool(db, "rename_column", mustJSON(t, map[string]string{"id": col.ID, "name": "New"}))
mustOK(t, res)
cols, _ := db.ListColumns()
if cols[0].Name != "New" {
t.Fatalf("alias rename failed")
}
}
func TestExecuteTool_DeleteColumn(t *testing.T) {
db := setupTestDB(t)
created := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Tmp"}))
col := created.Result.(*Column)
res := executeTool(db, "delete_column", mustJSON(t, map[string]string{"id": col.ID}))
mustOK(t, res)
cols, _ := db.ListColumns()
if len(cols) != 0 {
t.Fatalf("expected 0 cols after delete, got %d", len(cols))
}
}
func TestExecuteTool_ReorderColumns(t *testing.T) {
db := setupTestDB(t)
a := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "A"})).Result.(*Column)
b := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "B"})).Result.(*Column)
c := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "C"})).Result.(*Column)
res := executeTool(db, "reorder_columns", mustJSON(t, map[string][]string{"ids": {c.ID, a.ID, b.ID}}))
mustOK(t, res)
cols, _ := db.ListColumns()
got := []string{cols[0].Name, cols[1].Name, cols[2].Name}
want := []string{"C", "A", "B"}
for i := range want {
if got[i] != want[i] {
t.Fatalf("reorder mismatch at %d: want %s got %s", i, want[i], got[i])
}
}
}
// --- create_card / update_card / delete_card / move_card ---
func TestExecuteTool_CreateCard_AndRequester(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Todo"})).Result.(*Column)
res := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": col.ID,
"requester": "Lucas",
"title": "Buy milk",
"description": "Whole milk",
}))
mustOK(t, res)
card := res.Result.(*Card)
if card.Requester != "Lucas" || card.Title != "Buy milk" || card.ColumnID != col.ID {
t.Fatalf("unexpected card: %+v", card)
}
}
func TestExecuteTool_CreateCard_MissingTitle(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
res := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": col.ID,
"title": "",
}))
mustErr(t, res, "required")
}
func TestExecuteTool_UpdateCard(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": col.ID,
"requester": "A",
"title": "T1",
})).Result.(*Card)
newTitle := "T2"
newReq := "B"
color := "violet"
res := executeTool(db, "update_card", mustJSON(t, map[string]any{
"id": card.ID,
"title": newTitle,
"requester": newReq,
"color": color,
}))
mustOK(t, res)
cards, _ := db.ListCardsWithTime()
if cards[0].Title != "T2" || cards[0].Requester != "B" || cards[0].Color != "violet" {
t.Fatalf("unexpected card after update: %+v", cards[0])
}
}
func TestExecuteTool_DeleteCard(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": col.ID,
"title": "T",
})).Result.(*Card)
res := executeTool(db, "delete_card", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, res)
cards, _ := db.ListCardsWithTime()
if len(cards) != 0 {
t.Fatalf("expected 0 cards, got %d", len(cards))
}
}
func TestExecuteTool_MoveCard_BetweenColumns_OpensHistory(t *testing.T) {
db := setupTestDB(t)
src := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Src"})).Result.(*Column)
dst := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Dst"})).Result.(*Column)
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": src.ID,
"title": "Move me",
})).Result.(*Card)
res := executeTool(db, "move_card", mustJSON(t, map[string]any{
"id": card.ID,
"column_id": dst.ID,
}))
mustOK(t, res)
cards, _ := db.ListCardsWithTime()
if cards[0].ColumnID != dst.ID {
t.Fatalf("card not moved, still in %s", cards[0].ColumnID)
}
histRes := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, histRes)
hist := histRes.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 2 {
t.Fatalf("expected 2 history entries, got %d", len(hist))
}
if hist[0].ExitedAt == nil {
t.Fatalf("first entry should be closed")
}
if hist[1].ExitedAt != nil {
t.Fatalf("second entry should be open")
}
}
func TestExecuteTool_MoveCard_RequiresIDAndColumn(t *testing.T) {
db := setupTestDB(t)
res := executeTool(db, "move_card", mustJSON(t, map[string]string{"id": ""}))
mustErr(t, res, "required")
}
// --- card_history ---
func TestExecuteTool_CardHistory_Single(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
card := executeTool(db, "create_card", mustJSON(t, map[string]string{
"column_id": col.ID,
"title": "T",
})).Result.(*Card)
res := executeTool(db, "card_history", mustJSON(t, map[string]string{"id": card.ID}))
mustOK(t, res)
hist := res.Result.(*CardHistoryResponse).ColumnHistory
if len(hist) != 1 || hist[0].ExitedAt != nil {
t.Fatalf("expected 1 open history entry, got %+v", hist)
}
}
// --- find_cards ---
func TestExecuteTool_FindCards_FilterByQueryRequesterColumn(t *testing.T) {
db := setupTestDB(t)
col := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "X"})).Result.(*Column)
col2 := executeTool(db, "create_column", mustJSON(t, map[string]string{"name": "Y"})).Result.(*Column)
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Lucas", "title": "Bug fix"}))
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col.ID, "requester": "Ana", "title": "Feature x"}))
executeTool(db, "create_card", mustJSON(t, map[string]string{"column_id": col2.ID, "requester": "Lucas", "title": "Refactor"}))
// query
r := executeTool(db, "find_cards", mustJSON(t, map[string]string{"query": "fix"}))
mustOK(t, r)
cards := r.Result.([]Card)
if len(cards) != 1 || cards[0].Title != "Bug fix" {
t.Fatalf("query filter failed: %+v", cards)
}
// requester
r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"requester": "Lucas"}))
cards = r.Result.([]Card)
if len(cards) != 2 {
t.Fatalf("requester filter expected 2 got %d", len(cards))
}
// column
r = executeTool(db, "find_cards", mustJSON(t, map[string]string{"column_id": col2.ID}))
cards = r.Result.([]Card)
if len(cards) != 1 || cards[0].ColumnID != col2.ID {
t.Fatalf("column filter failed: %+v", cards)
}
// combined
r = executeTool(db, "find_cards", mustJSON(t, map[string]any{"requester": "Lucas", "column_id": col.ID}))
cards = r.Result.([]Card)
if len(cards) != 1 || cards[0].Title != "Bug fix" {
t.Fatalf("combined filter failed: %+v", cards)
}
}
// --- unknown tool ---
func TestExecuteTool_Unknown(t *testing.T) {
db := setupTestDB(t)
res := executeTool(db, "no_such_tool", json.RawMessage(`{}`))
mustErr(t, res, "unknown tool")
}
// --- chat logger ---
func TestChatLogger_AppendsJSONLines(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "chat.log")
logger := newChatLogger(path)
logger.Log("create_column", json.RawMessage(`{"name":"A"}`), ToolResult{OK: true, Result: &Column{ID: "abc", Name: "A"}})
logger.Log("delete_card", json.RawMessage(`{"id":"x"}`), ToolResult{OK: false, Error: "card not found"})
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read log: %v", err)
}
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
if len(lines) != 2 {
t.Fatalf("expected 2 log lines, got %d", len(lines))
}
for i, line := range lines {
var entry ChatLogEntry
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Fatalf("line %d not valid JSON: %v\n%s", i, err, line)
}
if entry.TS == "" {
t.Fatalf("line %d missing TS", i)
}
}
var first, second ChatLogEntry
json.Unmarshal([]byte(lines[0]), &first)
json.Unmarshal([]byte(lines[1]), &second)
if first.Tool != "create_column" || !first.OK {
t.Fatalf("unexpected first entry: %+v", first)
}
if second.Tool != "delete_card" || second.OK || second.Error != "card not found" {
t.Fatalf("unexpected second entry: %+v", second)
}
}
// --- toolMutates ---
func TestToolMutates(t *testing.T) {
mutating := []string{"create_column", "update_column", "rename_column", "delete_column", "reorder_columns",
"create_card", "update_card", "delete_card", "move_card"}
readonly := []string{"list_board", "card_history", "find_cards"}
for _, n := range mutating {
if !toolMutates(n) {
t.Errorf("expected %s to mutate", n)
}
}
for _, n := range readonly {
if toolMutates(n) {
t.Errorf("expected %s to be read-only", n)
}
}
}