diff options
Diffstat (limited to 'internal/api/executions_test.go')
| -rw-r--r-- | internal/api/executions_test.go | 280 |
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"]) + } +} |
