summaryrefslogtreecommitdiff
path: root/internal/api/executions_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/executions_test.go')
-rw-r--r--internal/api/executions_test.go280
1 files changed, 280 insertions, 0 deletions
diff --git a/internal/api/executions_test.go b/internal/api/executions_test.go
new file mode 100644
index 0000000..a2bba21
--- /dev/null
+++ b/internal/api/executions_test.go
@@ -0,0 +1,280 @@
+package api
+
+import (
+ "bufio"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+ "github.com/thepeterstone/claudomator/internal/task"
+)
+
+// createExecution inserts a test execution directly into the store.
+func createExecution(t *testing.T, store *storage.DB, id, taskID string, start, end time.Time, status string) *storage.Execution {
+ t.Helper()
+ exec := &storage.Execution{
+ ID: id,
+ TaskID: taskID,
+ StartTime: start,
+ EndTime: end,
+ ExitCode: 0,
+ Status: status,
+ CostUSD: 0.001,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("createExecution: %v", err)
+ }
+ return exec
+}
+
+func TestListRecentExecutions_Empty(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("GET", "/api/executions", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var execs []storage.RecentExecution
+ if err := json.NewDecoder(w.Body).Decode(&execs); err != nil {
+ t.Fatalf("decoding response: %v", err)
+ }
+ if len(execs) != 0 {
+ t.Errorf("want 0 executions, got %d", len(execs))
+ }
+}
+
+func TestListRecentExecutions_ReturnsCorrectShape(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "exec-shape", task.StateCompleted)
+ now := time.Now().UTC().Truncate(time.Second)
+ end := now.Add(5 * time.Second)
+ exec := &storage.Execution{
+ ID: "e-shape",
+ TaskID: tk.ID,
+ StartTime: now,
+ EndTime: end,
+ ExitCode: 0,
+ Status: "COMPLETED",
+ CostUSD: 0.042,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("creating execution: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var execs []storage.RecentExecution
+ if err := json.NewDecoder(w.Body).Decode(&execs); err != nil {
+ t.Fatalf("decoding response: %v", err)
+ }
+ if len(execs) != 1 {
+ t.Fatalf("want 1 execution, got %d", len(execs))
+ }
+ e := execs[0]
+ if e.ID != "e-shape" {
+ t.Errorf("id: want e-shape, got %q", e.ID)
+ }
+ if e.TaskID != tk.ID {
+ t.Errorf("task_id: want %q, got %q", tk.ID, e.TaskID)
+ }
+ if e.TaskName != tk.Name {
+ t.Errorf("task_name: want %q, got %q", tk.Name, e.TaskName)
+ }
+ if e.State != "COMPLETED" {
+ t.Errorf("state: want COMPLETED, got %q", e.State)
+ }
+ if e.CostUSD != 0.042 {
+ t.Errorf("cost_usd: want 0.042, got %f", e.CostUSD)
+ }
+ if e.DurationMS == nil {
+ t.Error("duration_ms: want non-nil for completed execution")
+ } else if *e.DurationMS != 5000 {
+ t.Errorf("duration_ms: want 5000, got %d", *e.DurationMS)
+ }
+}
+
+func TestListRecentExecutions_SinceFilter(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk := createTaskWithState(t, store, "exec-since", task.StateCompleted)
+ oldStart := time.Now().UTC().Add(-48 * time.Hour)
+ recentStart := time.Now().UTC()
+
+ for i, start := range []time.Time{oldStart, recentStart} {
+ createExecution(t, store, fmt.Sprintf("e-since-%d", i), tk.ID, start, start.Add(time.Minute), "COMPLETED")
+ }
+
+ since := time.Now().UTC().Add(-25 * time.Hour).Format(time.RFC3339)
+ req := httptest.NewRequest("GET", "/api/executions?since="+since, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var execs []storage.RecentExecution
+ json.NewDecoder(w.Body).Decode(&execs)
+ if len(execs) != 1 {
+ t.Errorf("since filter: want 1 execution, got %d", len(execs))
+ }
+}
+
+func TestListRecentExecutions_TaskIDFilter(t *testing.T) {
+ srv, store := testServer(t)
+
+ tk1 := createTaskWithState(t, store, "filter-t1", task.StateCompleted)
+ tk2 := createTaskWithState(t, store, "filter-t2", task.StateCompleted)
+ now := time.Now().UTC()
+
+ createExecution(t, store, "e-filter-0", tk1.ID, now, now.Add(time.Minute), "COMPLETED")
+ createExecution(t, store, "e-filter-1", tk2.ID, now, now.Add(time.Minute), "COMPLETED")
+
+ req := httptest.NewRequest("GET", "/api/executions?task_id="+tk1.ID, nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ var execs []storage.RecentExecution
+ json.NewDecoder(w.Body).Decode(&execs)
+ if len(execs) != 1 {
+ t.Errorf("task_id filter: want 1 execution, got %d", len(execs))
+ }
+ if len(execs) > 0 && execs[0].TaskID != tk1.ID {
+ t.Errorf("task_id filter: want task_id=%q, got %q", tk1.ID, execs[0].TaskID)
+ }
+}
+
+func TestGetExecutionLog_NotFound(t *testing.T) {
+ srv, _ := testServer(t)
+
+ req := httptest.NewRequest("GET", "/api/executions/nonexistent/log", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status: want 404, got %d", w.Code)
+ }
+}
+
+func TestGetExecutionLog_TailLines(t *testing.T) {
+ srv, store := testServer(t)
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ var lines []string
+ for i := 0; i < 10; i++ {
+ lines = append(lines, fmt.Sprintf(`{"type":"text","line":%d}`, i))
+ }
+ if err := os.WriteFile(logPath, []byte(strings.Join(lines, "\n")+"\n"), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ tk := createTaskWithState(t, store, "log-tail", task.StateCompleted)
+ exec := &storage.Execution{
+ ID: "e-tail",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
+ StdoutPath: logPath,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("creating execution: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions/e-tail/log?tail=3", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if w.Code != http.StatusOK {
+ t.Fatalf("status: want 200, got %d; body: %s", w.Code, w.Body.String())
+ }
+ if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/plain") {
+ t.Errorf("content-type: want text/plain, got %q", ct)
+ }
+
+ scanner := bufio.NewScanner(w.Body)
+ var got []string
+ for scanner.Scan() {
+ if line := scanner.Text(); line != "" {
+ got = append(got, line)
+ }
+ }
+ if len(got) != 3 {
+ t.Errorf("tail=3: want 3 lines, got %d: %v", len(got), got)
+ }
+ if len(got) > 0 && !strings.Contains(got[0], `"line":7`) {
+ t.Errorf("first tail line: want line 7, got %q", got[0])
+ }
+}
+
+func TestGetExecutionLog_FollowSSEHeaders(t *testing.T) {
+ srv, store := testServer(t)
+
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ os.WriteFile(logPath, []byte(`{"type":"result","cost_usd":0.001}`+"\n"), 0600)
+
+ tk := createTaskWithState(t, store, "log-sse", task.StateCompleted)
+ exec := &storage.Execution{
+ ID: "e-sse",
+ TaskID: tk.ID,
+ StartTime: time.Now().UTC(),
+ EndTime: time.Now().UTC().Add(time.Minute),
+ Status: "COMPLETED",
+ StdoutPath: logPath,
+ }
+ if err := store.CreateExecution(exec); err != nil {
+ t.Fatalf("creating execution: %v", err)
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions/e-sse/log?follow=true", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ if ct := w.Header().Get("Content-Type"); ct != "text/event-stream" {
+ t.Errorf("content-type: want text/event-stream, got %q", ct)
+ }
+ if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("cache-control: want no-cache, got %q", cc)
+ }
+}
+
+func TestListTasks_ReturnsStateField(t *testing.T) {
+ srv, store := testServer(t)
+ createTaskWithState(t, store, "state-check", task.StateRunning)
+
+ req := httptest.NewRequest("GET", "/api/tasks", nil)
+ w := httptest.NewRecorder()
+ srv.Handler().ServeHTTP(w, req)
+
+ var tasks []map[string]interface{}
+ json.NewDecoder(w.Body).Decode(&tasks)
+ if len(tasks) != 1 {
+ t.Fatalf("want 1 task, got %d", len(tasks))
+ }
+ if state, ok := tasks[0]["state"]; !ok || state == nil {
+ t.Error("task response missing 'state' field")
+ }
+ if tasks[0]["state"] != string(task.StateRunning) {
+ t.Errorf("state: want %q, got %q", task.StateRunning, tasks[0]["state"])
+ }
+}