package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "net/http/httptest" "os" "os/exec" "path/filepath" "strings" "testing" ) func setupApp(t *testing.T) (*App, func()) { t.Helper() dir := t.TempDir() dbPath := filepath.Join(dir, "test.db") // Init a throwaway git repo so worktree commands work repoRoot := filepath.Join(dir, "repo") if err := os.MkdirAll(repoRoot, 0o755); err != nil { t.Fatalf("mkdir repo: %v", err) } mustRun(t, repoRoot, "git", "init", "-b", "master") mustRun(t, repoRoot, "git", "config", "user.email", "test@local") mustRun(t, repoRoot, "git", "config", "user.name", "test") // commit something readme := filepath.Join(repoRoot, "README.md") _ = os.WriteFile(readme, []byte("hi\n"), 0o644) mustRun(t, repoRoot, "git", "add", "-A") mustRun(t, repoRoot, "git", "commit", "-m", "initial") db, err := openDB(dbPath) if err != nil { t.Fatalf("openDB: %v", err) } app := &App{ cfg: Config{ Port: 0, DBPath: dbPath, RepoRoot: repoRoot, WorktreesRoot: filepath.Join(dir, "worktrees"), }, db: db, sse: newSSEHub(), } t.Setenv("AGENT_RUNNER_STUB", "1") cleanup := func() { db.Close() } return app, cleanup } func mustRun(t *testing.T, dir, name string, args ...string) { t.Helper() cmd := exec.Command(name, args...) cmd.Dir = dir out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("%s %v: %v: %s", name, args, err, string(out)) } } func TestHealth(t *testing.T) { app, cleanup := setupApp(t) defer cleanup() req := httptest.NewRequest(http.MethodGet, "/api/health", nil) rec := httptest.NewRecorder() app.handleHealth(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d", rec.Code) } var body map[string]interface{} _ = json.Unmarshal(rec.Body.Bytes(), &body) if body["status"] != "ok" { t.Fatalf("unexpected body: %v", body) } } func TestCreateRun(t *testing.T) { app, cleanup := setupApp(t) defer cleanup() payload := map[string]string{ "issue_id": "0999", "mode": "agent", "prompt": "test prompt", } b, _ := json.Marshal(payload) req := httptest.NewRequest(http.MethodPost, "/api/runs", bytes.NewReader(b)) rec := httptest.NewRecorder() app.handleCreateRun(rec, req) if rec.Code != http.StatusCreated { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var res createRunResponse if err := json.Unmarshal(rec.Body.Bytes(), &res); err != nil { t.Fatalf("json: %v", err) } if !strings.HasPrefix(res.RunID, "run_") { t.Fatalf("bad run_id: %s", res.RunID) } if !strings.HasPrefix(res.Branch, "auto/") { t.Fatalf("bad branch: %s", res.Branch) } // Verify row inserted var status string if err := app.db.QueryRow(`SELECT status FROM runs WHERE id = ?`, res.RunID).Scan(&status); err != nil { t.Fatalf("query: %v", err) } if status != "running" && status != "pending" { t.Fatalf("expected running/pending, got %s", status) } // Verify worktree row var count int _ = app.db.QueryRow(`SELECT COUNT(*) FROM worktrees WHERE run_id = ?`, res.RunID).Scan(&count) if count != 1 { t.Fatalf("expected 1 worktree row, got %d", count) } } func TestAbortRun(t *testing.T) { app, cleanup := setupApp(t) defer cleanup() // Create a run payload := map[string]string{"issue_id": "abort_test", "prompt": "x"} b, _ := json.Marshal(payload) rec := httptest.NewRecorder() app.handleCreateRun(rec, httptest.NewRequest(http.MethodPost, "/api/runs", bytes.NewReader(b))) if rec.Code != http.StatusCreated { t.Fatalf("create failed: %d %s", rec.Code, rec.Body.String()) } var res createRunResponse _ = json.Unmarshal(rec.Body.Bytes(), &res) // Abort rec2 := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/runs/%s/abort", res.RunID), nil) app.handleAbortRun(rec2, req, res.RunID) if rec2.Code != http.StatusOK { t.Fatalf("abort status=%d body=%s", rec2.Code, rec2.Body.String()) } var status string _ = app.db.QueryRow(`SELECT status FROM runs WHERE id = ?`, res.RunID).Scan(&status) if status != "aborted" { t.Fatalf("expected aborted, got %s", status) } // Worktree row marked removed var removed bool _ = app.db.QueryRow(`SELECT removed_at IS NOT NULL FROM worktrees WHERE run_id = ?`, res.RunID).Scan(&removed) if !removed { t.Fatalf("expected worktree removed_at populated") } } func TestEvidencePersist(t *testing.T) { app, cleanup := setupApp(t) defer cleanup() // Create run b, _ := json.Marshal(map[string]string{"issue_id": "ev_test"}) rec := httptest.NewRecorder() app.handleCreateRun(rec, httptest.NewRequest(http.MethodPost, "/api/runs", bytes.NewReader(b))) var run createRunResponse _ = json.Unmarshal(rec.Body.Bytes(), &run) // Attach evidence with auto-create item text := "tests pass" evReq := evidenceRequest{ ItemKey: "tests_green", Kind: "text", PayloadText: &text, } body, _ := json.Marshal(evReq) rec2 := httptest.NewRecorder() app.handleAttachEvidence(rec2, httptest.NewRequest(http.MethodPost, fmt.Sprintf("/api/runs/%s/evidence", run.RunID), bytes.NewReader(body)), run.RunID) if rec2.Code != http.StatusCreated { t.Fatalf("evidence status=%d body=%s", rec2.Code, rec2.Body.String()) } // Verify rows var itemCount, evCount int _ = app.db.QueryRow(`SELECT COUNT(*) FROM dod_items WHERE run_id = ?`, run.RunID).Scan(&itemCount) _ = app.db.QueryRow(`SELECT COUNT(*) FROM dod_evidence`).Scan(&evCount) if itemCount != 1 || evCount != 1 { t.Fatalf("expected 1+1, got items=%d evidence=%d", itemCount, evCount) } // Status should have bumped to 'done' var status string _ = app.db.QueryRow(`SELECT status FROM dod_items WHERE run_id = ?`, run.RunID).Scan(&status) if status != "done" { t.Fatalf("expected done, got %s", status) } } func TestListFilter(t *testing.T) { app, cleanup := setupApp(t) defer cleanup() // Insert two runs with different kanban_app (unique issue_ids to avoid branch collision) for i, kapp := range []string{"kanban_a", "kanban_b", "kanban_a"} { k := kapp b, _ := json.Marshal(map[string]string{ "kanban_app": k, "issue_id": fmt.Sprintf("x_%s_%d", k, i), }) rec := httptest.NewRecorder() app.handleCreateRun(rec, httptest.NewRequest(http.MethodPost, "/api/runs", bytes.NewReader(b))) if rec.Code != http.StatusCreated { t.Fatalf("setup row failed: %s", rec.Body.String()) } } rec := httptest.NewRecorder() req := httptest.NewRequest(http.MethodGet, "/api/runs?app=kanban_a", nil) app.handleListRuns(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status=%d body=%s", rec.Code, rec.Body.String()) } var runs []Run _ = json.Unmarshal(rec.Body.Bytes(), &runs) if len(runs) != 2 { t.Fatalf("expected 2 runs for kanban_a, got %d", len(runs)) } for _, r := range runs { if r.KanbanApp == nil || *r.KanbanApp != "kanban_a" { t.Fatalf("unexpected kanban_app: %v", r.KanbanApp) } } } // drain reads to EOF (used to discard test response bodies). Not strictly needed // for httptest but kept for future use. func drain(rc io.ReadCloser) { _, _ = io.Copy(io.Discard, rc); rc.Close() }