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) } }