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.([]HistoryEntry) 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.([]HistoryEntry) 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") } // --- extractActions --- func TestExtractActions(t *testing.T) { cases := []struct { name string in string want string stripOK string found bool }{ {"with block", "Hola\n[{\"tool\":\"x\"}]\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true}, {"only block", "[]", `[]`, "", true}, {"no block", "Solo texto", "", "Solo texto", false}, {"unclosed", "foo", "", "foo", false}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { got, stripped, found := extractActions(c.in) if found != c.found { t.Fatalf("found = %v want %v", found, c.found) } if got != c.want { t.Fatalf("got %q want %q", got, c.want) } if stripped != c.stripOK { t.Fatalf("stripped = %q want %q", stripped, c.stripOK) } }) } } // --- 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) } } }