Files
kanban_cpp/backend/issues_source_test.go
agent 0b93a985d6 feat(backend): issues/flows sync layer (issue 0119)
Read dev/issues/*.md and dev/flows/*.md as kanban cards via new
/api/boards/{issues|flows}/cards endpoints. PATCH writes status back
to the frontmatter atomically (tmp + rename), POST .../launch proxies
to agent_runner_api.

- issues_source.go: scan + parse frontmatter (yaml.v3) into IssueCard.
  Skips README/INDEX/AGENT_GUIDE. Malformed YAML yields parse-error
  cards (no crash). Description = first 5 body lines (no full body).
- flows_source.go: same shape, distinct status->column mapping
  (pending/running/done/deferred -> Pending/Running/Done/Deferred).
- frontmatter_edit.go: PatchFrontmatterField — atomic, preserves the
  rest of the file byte-for-byte, inserts key if missing.
- handlers_boards.go: list + patch + launch endpoints, taxonomy 0103
  enforced. Cache 30s in memory, thread-safe (mutex), invalidated on
  PATCH. Launch returns 502 with suggestion when runner is down.
- main.go: SkipPaths += "/api/boards/" so the C++ frontend hits the
  read endpoints without a kanban_web session.

Smoke (FN_REGISTRY_ROOT pointed at the worktree, 87 issues + 9 flows
on disk):
  GET  /api/boards/issues/cards -> 200, 87 cards
  GET  /api/boards/flows/cards  -> 200, 9 cards
  PATCH /api/boards/issues/cards/0119 {status:en-curso} -> 200,
    file mtime changes, frontmatter rewritten, rest preserved
  POST /api/boards/issues/cards/0119/launch -> 502
    agent_runner_unreachable (expected, runner not yet implemented)

Tests: issues_source_test (3 cases incl. malformed + missing status),
flows_source_test (3 cases), frontmatter_edit_test (4 cases incl.
atomic rename + no tmp leftovers). Pre-existing tools_test failure
on TestExecuteTool_MoveCard_BetweenColumns_OpensHistory is unrelated
(CardHistoryResponse type assert, not touched here).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:56:22 +02:00

120 lines
3.3 KiB
Go

package main
import (
"os"
"path/filepath"
"testing"
)
func writeFixture(t *testing.T, dir, name, content string) {
t.Helper()
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
func TestLoadIssueCards_MapsStatusesAndSkipsNonIssues(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "README.md", "skip me")
writeFixture(t, dir, "AGENT_GUIDE.md", "skip me too")
writeFixture(t, dir, "0001-foo.md", "---\n"+
"id: \"0001\"\n"+
"title: \"Foo\"\n"+
"status: pendiente\n"+
"priority: alta\n"+
"type: feature\n"+
"tags: [x, y]\n"+
"flow: \"0008\"\n"+
"created: 2026-05-18\n"+
"updated: 2026-05-18\n"+
"---\n# Foo\n\nDescription body line 1.\nLine 2.\n")
writeFixture(t, dir, "0002-bar.md", "---\n"+
"id: \"0002\"\n"+
"title: \"Bar\"\n"+
"status: en-curso\n"+
"---\nBody\n")
writeFixture(t, dir, "0003-baz.md", "---\n"+
"id: \"0003\"\n"+
"title: \"Baz\"\n"+
"status: done\n"+
"---\nBody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 3 {
t.Fatalf("expected 3 cards, got %d: %#v", len(cards), cards)
}
byID := map[string]IssueCard{}
for _, c := range cards {
byID[c.ID] = c
}
if c := byID["0001"]; c.ColumnID != "Backlog" || c.Priority != "alta" || c.FlowID != "0008" || c.Type != "feature" {
t.Fatalf("0001 mismapped: %#v", c)
}
if c := byID["0002"]; c.ColumnID != "Doing" {
t.Fatalf("0002 expected Doing, got %s", c.ColumnID)
}
if c := byID["0003"]; c.ColumnID != "Done" {
t.Fatalf("0003 expected Done, got %s", c.ColumnID)
}
// Description must contain body content but NOT the title heading.
if c := byID["0001"]; c.Description == "" {
t.Fatalf("0001 missing description")
}
}
func TestLoadIssueCards_MalformedYAMLDoesNotCrash(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0010-bad.md", "---\nid: \"0010\"\ntitle: \"Bad\"\nstatus: pendiente\n : malformed\n---\nbody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("expected no top-level error, got: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card (with parse error), got %d", len(cards))
}
if cards[0].ParseError == "" {
t.Fatalf("expected ParseError to be set on malformed card")
}
if cards[0].ID != "0010" {
t.Fatalf("expected id 0010 derived from filename, got %q", cards[0].ID)
}
}
func TestLoadIssueCards_MissingStatusDefaultsPendiente(t *testing.T) {
dir := t.TempDir()
writeFixture(t, dir, "0011-nostatus.md", "---\nid: \"0011\"\ntitle: \"NoStatus\"\n---\nbody\n")
cards, err := loadIssueCards(dir)
if err != nil {
t.Fatalf("load: %v", err)
}
if len(cards) != 1 {
t.Fatalf("expected 1 card, got %d", len(cards))
}
if cards[0].Status != "pendiente" {
t.Fatalf("expected default status pendiente, got %q", cards[0].Status)
}
if cards[0].ColumnID != "Backlog" {
t.Fatalf("expected column Backlog, got %q", cards[0].ColumnID)
}
}
func TestIssuesCacheTTL(t *testing.T) {
// Sanity: a fresh cache misses.
cache := &cardsCache{ttl: 30 * 1_000_000_000} // 30s in ns
if _, ok := cache.get(); ok {
t.Fatalf("expected miss on empty cache")
}
cache.set([]IssueCard{{ID: "0001"}})
if c, ok := cache.get(); !ok || len(c) != 1 {
t.Fatalf("expected cache hit")
}
cache.invalidate()
if _, ok := cache.get(); ok {
t.Fatalf("expected miss after invalidate")
}
}