diff options
Diffstat (limited to 'internal/api/logs_test.go')
| -rw-r--r-- | internal/api/logs_test.go | 175 |
1 files changed, 175 insertions, 0 deletions
diff --git a/internal/api/logs_test.go b/internal/api/logs_test.go index 4a0c9fd..52fa168 100644 --- a/internal/api/logs_test.go +++ b/internal/api/logs_test.go @@ -14,6 +14,26 @@ import ( "github.com/thepeterstone/claudomator/internal/storage" ) +// mockTaskLogStore implements taskLogStore for testing handleStreamTaskLogs. +type mockTaskLogStore struct { + getExecution func(id string) (*storage.Execution, error) + getLatestExecution func(taskID string) (*storage.Execution, error) +} + +func (m *mockTaskLogStore) GetExecution(id string) (*storage.Execution, error) { + return m.getExecution(id) +} + +func (m *mockTaskLogStore) GetLatestExecution(taskID string) (*storage.Execution, error) { + return m.getLatestExecution(taskID) +} + +func taskLogsMux(srv *Server) *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("GET /api/tasks/{id}/logs/stream", srv.handleStreamTaskLogs) + return mux +} + // mockLogStore implements logStore for testing. type mockLogStore struct { fn func(id string) (*storage.Execution, error) @@ -170,3 +190,158 @@ func TestHandleStreamLogs_RunningState_LiveTail(t *testing.T) { t.Errorf("body does not end with done event; got:\n%s", body) } } + +// --- Task-level SSE log streaming tests (handleStreamTaskLogs) --- + +// TestHandleStreamTaskLogs_TaskNotFound verifies that a task with no executions yields 404. +func TestHandleStreamTaskLogs_TaskNotFound(t *testing.T) { + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return nil, errors.New("not found") + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/nonexistent/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("status: want 404, got %d; body: %s", w.Code, w.Body.String()) + } +} + +// TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone verifies that a completed execution with no +// stdout log path emits only the done sentinel event. +func TestHandleStreamTaskLogs_NoStdoutPath_EmitsDone(t *testing.T) { + exec := &storage.Execution{ + ID: "exec-task-empty", + TaskID: "task-no-log", + StartTime: time.Now(), + Status: "COMPLETED", + // StdoutPath intentionally empty + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-no-log/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(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 body != "event: done\ndata: {}\n\n" { + t.Errorf("want only done event, got:\n%s", body) + } +} + +// TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone verifies that a COMPLETED +// execution streams SSE events and ends with a done event. +func TestHandleStreamTaskLogs_TerminalExecution_EmitsEventsAndDone(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + lines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Task output here"}]}}`, + `{"type":"result","cost_usd":0.007}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(lines), 0600); err != nil { + t.Fatal(err) + } + + exec := &storage.Execution{ + ID: "exec-task-done", + TaskID: "task-done", + StartTime: time.Now(), + Status: "COMPLETED", + StdoutPath: logPath, + } + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return exec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + return exec, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-done/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Task output here"`) { + t.Errorf("expected text event content 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) + } +} + +// TestHandleStreamTaskLogs_RunningExecution_LiveTails verifies that a RUNNING execution is +// live-tailed and a done event is emitted once it transitions to a terminal state. +func TestHandleStreamTaskLogs_RunningExecution_LiveTails(t *testing.T) { + dir := t.TempDir() + logPath := filepath.Join(dir, "stdout.log") + logLines := strings.Join([]string{ + `{"type":"assistant","message":{"content":[{"type":"text","text":"Still running..."}]}}`, + `{"type":"result","cost_usd":0.003}`, + }, "\n") + "\n" + if err := os.WriteFile(logPath, []byte(logLines), 0600); err != nil { + t.Fatal(err) + } + + runningExec := &storage.Execution{ + ID: "exec-task-running", + TaskID: "task-running", + StartTime: time.Now(), + Status: "RUNNING", + StdoutPath: logPath, + } + + // getLatestExecution is called once (initial lookup); getExecution polls for state change. + var pollCount atomic.Int32 + srv := &Server{ + taskLogStore: &mockTaskLogStore{ + getLatestExecution: func(taskID string) (*storage.Execution, error) { + return runningExec, nil + }, + getExecution: func(id string) (*storage.Execution, error) { + n := pollCount.Add(1) + if n <= 1 { + return runningExec, nil + } + completed := *runningExec + completed.Status = "COMPLETED" + return &completed, nil + }, + }, + } + + req := httptest.NewRequest("GET", "/api/tasks/task-running/logs/stream", nil) + w := httptest.NewRecorder() + taskLogsMux(srv).ServeHTTP(w, req) + + body := w.Body.String() + if !strings.Contains(body, `"Still running..."`) { + t.Errorf("expected live-tail text 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) + } +} |
