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 TestListRecentExecutions_LimitClamped(t *testing.T) { srv, _ := testServer(t) req := httptest.NewRequest("GET", "/api/executions?limit=10000000", 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()) } // The handler should not pass limit > maxLimit to the store. // We verify indirectly: if the query param is accepted without error and // does not cause a panic or 500, the clamp is in effect. // A direct assertion requires a mock store; here we check the response is valid. var execs []storage.RecentExecution if err := json.NewDecoder(w.Body).Decode(&execs); err != nil { t.Fatalf("decoding response: %v", err) } } 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"]) } }