431 lines
13 KiB
Go
431 lines
13 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.([]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<actions>[{\"tool\":\"x\"}]</actions>\nHecho", `[{"tool":"x"}]`, "Hola\nHecho", true},
|
|
{"only block", "<actions>[]</actions>", `[]`, "", true},
|
|
{"no block", "Solo texto", "", "Solo texto", false},
|
|
{"unclosed", "<actions>foo", "", "<actions>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)
|
|
}
|
|
}
|
|
}
|