summaryrefslogtreecommitdiff
path: root/internal/api/logs_test.go
diff options
context:
space:
mode:
Diffstat (limited to 'internal/api/logs_test.go')
-rw-r--r--internal/api/logs_test.go172
1 files changed, 172 insertions, 0 deletions
diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go
new file mode 100644
index 0000000..4a0c9fd
--- /dev/null
+++ b/internal/api/logs_test.go
@@ -0,0 +1,172 @@
+package api
+
+import (
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/thepeterstone/claudomator/internal/storage"
+)
+
+// mockLogStore implements logStore for testing.
+type mockLogStore struct {
+ fn func(id string) (*storage.Execution, error)
+}
+
+func (m *mockLogStore) GetExecution(id string) (*storage.Execution, error) {
+ return m.fn(id)
+}
+
+func logsMux(srv *Server) *http.ServeMux {
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/executions/{id}/logs", srv.handleStreamLogs)
+ return mux
+}
+
+// TestHandleStreamLogs_NotFound verifies that an unknown execution ID yields 404.
+func TestHandleStreamLogs_NotFound(t *testing.T) {
+ srv := &Server{
+ logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) {
+ return nil, errors.New("not found")
+ }},
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions/nonexistent/logs", nil)
+ w := httptest.NewRecorder()
+ logsMux(srv).ServeHTTP(w, req)
+
+ if w.Code != http.StatusNotFound {
+ t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String())
+ }
+}
+
+// TestHandleStreamLogs_TerminalState_EmitsEventsAndDone verifies that a COMPLETED
+// execution with a populated stdout.log streams SSE events and terminates with a done event.
+func TestHandleStreamLogs_TerminalState_EmitsEventsAndDone(t *testing.T) {
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ lines := strings.Join([]string{
+ `{"type":"assistant","message":{"content":[{"type":"text","text":"Hello world"}]}}`,
+ `{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Bash","input":{"command":"ls"}}]}}`,
+ `{"type":"result","cost_usd":0.0042}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ exec := &storage.Execution{
+ ID: "exec-terminal-1",
+ TaskID: "task-terminal-1",
+ StartTime: time.Now(),
+ Status: "COMPLETED",
+ StdoutPath: logPath,
+ }
+ srv := &Server{
+ logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) {
+ return exec, nil
+ }},
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions/exec-terminal-1/logs", nil)
+ w := httptest.NewRecorder()
+ logsMux(srv).ServeHTTP(w, req)
+
+ if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
+ t.Errorf("Content-Type: want text/event-stream, got %q", ct)
+ }
+ body := w.Body.String()
+ if !strings.Contains(body, "data: ") {
+ t.Error("expected at least one SSE 'data: ' event in body")
+ }
+ if !strings.Contains(body, "\n\n") {
+ t.Error("expected SSE double-newline event termination")
+ }
+ if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") {
+ t.Errorf("body does not end with done event; got:\n%s", body)
+ }
+}
+
+// TestHandleStreamLogs_EmptyLog verifies that a COMPLETED execution with no stdout path
+// responds with only the done sentinel event.
+func TestHandleStreamLogs_EmptyLog(t *testing.T) {
+ exec := &storage.Execution{
+ ID: "exec-empty-1",
+ TaskID: "task-empty-1",
+ StartTime: time.Now(),
+ Status: "COMPLETED",
+ // StdoutPath intentionally empty
+ }
+ srv := &Server{
+ logStore: &mockLogStore{fn: func(id string) (*storage.Execution, error) {
+ return exec, nil
+ }},
+ }
+
+ req := httptest.NewRequest("GET", "/api/executions/exec-empty-1/logs", nil)
+ w := httptest.NewRecorder()
+ logsMux(srv).ServeHTTP(w, req)
+
+ body := w.Body.String()
+ if body != "event: done\ndata: {}\n\n" {
+ t.Errorf("want only done event, got:\n%s", body)
+ }
+}
+
+// TestHandleStreamLogs_RunningState_LiveTail verifies that a RUNNING execution streams
+// initial log content and emits a done event once it transitions to a terminal state.
+func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) {
+ dir := t.TempDir()
+ logPath := filepath.Join(dir, "stdout.log")
+ logLines := strings.Join([]string{
+ `{"type":"assistant","message":{"content":[{"type":"text","text":"Working..."}]}}`,
+ `{"type":"result","cost_usd":0.001}`,
+ }, "\n") + "\n"
+ if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil {
+ t.Fatal(err)
+ }
+
+ runningExec := &storage.Execution{
+ ID: "exec-running-1",
+ TaskID: "task-running-1",
+ StartTime: time.Now(),
+ Status: "RUNNING",
+ StdoutPath: logPath,
+ }
+
+ // callCount tracks how many times GetExecution has been called.
+ // Call 1: initial fetch in handleStreamLogs → RUNNING
+ // Call 2+: poll in tailRunningExecution → COMPLETED
+ var callCount atomic.Int32
+ mock := &mockLogStore{fn: func(id string) (*storage.Execution, error) {
+ n := callCount.Add(1)
+ if n <= 1 {
+ return runningExec, nil
+ }
+ completed := *runningExec
+ completed.Status = "COMPLETED"
+ return &completed, nil
+ }}
+
+ srv := &Server{logStore: mock}
+
+ req := httptest.NewRequest("GET", "/api/executions/exec-running-1/logs", nil)
+ w := httptest.NewRecorder()
+ logsMux(srv).ServeHTTP(w, req)
+
+ body := w.Body.String()
+ if !strings.Contains(body, `"Working..."`) {
+ t.Errorf("expected initial text event in body; got:\n%s", body)
+ }
+ if !strings.Contains(body, `"type":"cost"`) {
+ t.Errorf("expected cost event in body; got:\n%s", body)
+ }
+ if !strings.HasSuffix(body, "event: done\ndata: {}\n\n") {
+ t.Errorf("body does not end with done event; got:\n%s", body)
+ }
+}