package handler import ( "encoding/json" "errors" "io" "log" "net/http" "net/http/httptest" "testing" "time" "github.com/gorilla/mux" "github.com/seifghazi/claude-code-monitor/internal/service" ) type conversationServiceStub struct { getConversationsFn func() (map[string][]*service.Conversation, error) getConversationFn func(projectPath, sessionID string) (*service.Conversation, error) getConversationsByProjectFn func(projectPath string) ([]*service.Conversation, error) } func (s *conversationServiceStub) GetConversations() (map[string][]*service.Conversation, error) { if s.getConversationsFn != nil { return s.getConversationsFn() } panic("unexpected call") } func (s *conversationServiceStub) GetConversation(projectPath, sessionID string) (*service.Conversation, error) { if s.getConversationFn != nil { return s.getConversationFn(projectPath, sessionID) } panic("unexpected call") } func (s *conversationServiceStub) GetConversationsByProject(projectPath string) ([]*service.Conversation, error) { if s.getConversationsByProjectFn != nil { return s.getConversationsByProjectFn(projectPath) } panic("unexpected call") } func newConversationHandler(stub *conversationServiceStub) *Handler { return &Handler{ conversationService: stub, logger: log.New(io.Discard, "", 0), } } func rawMessage(t *testing.T, v interface{}) json.RawMessage { t.Helper() data, err := json.Marshal(v) if err != nil { t.Fatalf("failed to marshal test raw message: %v", err) } return data } func TestConversationModelMatchesFilter(t *testing.T) { tests := []struct { name string model string filter string wantMatch bool }{ {name: "all matches any model", model: "claude-opus-4-6", filter: "all", wantMatch: true}, {name: "opus matches opus tier", model: "claude-opus-4-6", filter: "opus", wantMatch: true}, {name: "sonnet does not match opus tier", model: "claude-sonnet-4-5", filter: "opus", wantMatch: false}, {name: "empty model does not match tier filter", model: "", filter: "haiku", wantMatch: false}, {name: "substring filter still works", model: "claude-opus-4-6", filter: "opus-4", wantMatch: true}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := conversationModelMatchesFilter(tt.model, tt.filter); got != tt.wantMatch { t.Fatalf("conversationModelMatchesFilter(%q, %q) = %v, want %v", tt.model, tt.filter, got, tt.wantMatch) } }) } } func TestGetConversationsAppliesFilterSortAndPagination(t *testing.T) { oldest := time.Date(2026, 3, 18, 9, 0, 0, 0, time.UTC) middle := time.Date(2026, 3, 19, 9, 0, 0, 0, time.UTC) newest := time.Date(2026, 3, 20, 9, 0, 0, 0, time.UTC) stub := &conversationServiceStub{ getConversationsFn: func() (map[string][]*service.Conversation, error) { return map[string][]*service.Conversation{ "proj-a": { { SessionID: "old-opus", ProjectPath: "proj-a", ProjectName: "Proj A", Model: "claude-opus-4-6", StartTime: oldest.Add(-5 * time.Minute), EndTime: oldest, MessageCount: 1, Messages: []*service.ConversationMessage{ {Type: "user", Message: rawMessage(t, "short prompt")}, }, }, { SessionID: "new-sonnet", ProjectPath: "proj-a", ProjectName: "Proj A", Model: "claude-sonnet-4-5", StartTime: newest.Add(-2 * time.Minute), EndTime: newest, MessageCount: 2, Messages: []*service.ConversationMessage{ {Type: "user", Message: rawMessage(t, []map[string]string{{"type": "text", "text": "newest prompt"}})}, }, }, }, "proj-b": { { SessionID: "mid-opus", ProjectPath: "proj-b", ProjectName: "Proj B", Model: "claude-opus-4-6", StartTime: middle.Add(-3 * time.Minute), EndTime: middle, MessageCount: 3, Messages: []*service.ConversationMessage{ {Type: "user", Message: rawMessage(t, map[string]string{"content": "middle prompt"})}, }, }, }, }, nil }, } req := httptest.NewRequest(http.MethodGet, "/api/conversations?model=opus&page=1&limit=1", nil) rr := httptest.NewRecorder() newConversationHandler(stub).GetConversations(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response struct { Conversations []map[string]interface{} `json:"conversations"` HasMore bool `json:"hasMore"` Total int `json:"total"` Page int `json:"page"` Limit int `json:"limit"` } decodeJSONBody(t, rr, &response) if response.Total != 2 || !response.HasMore || response.Page != 1 || response.Limit != 1 { t.Fatalf("unexpected pagination metadata: %#v", response) } if len(response.Conversations) != 1 { t.Fatalf("expected one paginated conversation, got %d", len(response.Conversations)) } first := response.Conversations[0] if first["id"] != "mid-opus" { t.Fatalf("expected newest matching opus conversation first, got %#v", first) } if first["firstMessage"] != "middle prompt" { t.Fatalf("expected extracted first message, got %#v", first["firstMessage"]) } } func TestGetConversationsReturnsInternalServerErrorOnServiceFailure(t *testing.T) { stub := &conversationServiceStub{ getConversationsFn: func() (map[string][]*service.Conversation, error) { return nil, errors.New("boom") }, } req := httptest.NewRequest(http.MethodGet, "/api/conversations", nil) rr := httptest.NewRecorder() newConversationHandler(stub).GetConversations(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } var response struct { Error string `json:"error"` } decodeJSONBody(t, rr, &response) if response.Error != "Failed to get conversations" { t.Fatalf("unexpected error response: %#v", response) } } func TestGetConversationByIDRequiresProjectAndReturnsConversation(t *testing.T) { t.Run("missing project", func(t *testing.T) { req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/conversations/session-1", nil), map[string]string{"id": "session-1"}) rr := httptest.NewRecorder() newConversationHandler(&conversationServiceStub{}).GetConversationByID(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } var response struct { Error string `json:"error"` } decodeJSONBody(t, rr, &response) if response.Error != "Project path is required" { t.Fatalf("unexpected error response: %#v", response) } }) t.Run("success", func(t *testing.T) { stub := &conversationServiceStub{ getConversationFn: func(projectPath, sessionID string) (*service.Conversation, error) { if projectPath != "team/app" || sessionID != "session-1" { t.Fatalf("unexpected conversation lookup %q %q", projectPath, sessionID) } return &service.Conversation{ SessionID: "session-1", ProjectPath: "team/app", ProjectName: "app", Model: "claude-opus-4-6", StartTime: time.Date(2026, 3, 20, 8, 0, 0, 0, time.UTC), EndTime: time.Date(2026, 3, 20, 8, 5, 0, 0, time.UTC), MessageCount: 2, }, nil }, } req := mux.SetURLVars(httptest.NewRequest(http.MethodGet, "/api/conversations/session-1?project=team/app", nil), map[string]string{"id": "session-1"}) rr := httptest.NewRecorder() newConversationHandler(stub).GetConversationByID(rr, req) if rr.Code != http.StatusOK { t.Fatalf("expected 200, got %d", rr.Code) } var response service.Conversation decodeJSONBody(t, rr, &response) if response.SessionID != "session-1" || response.ProjectPath != "team/app" { t.Fatalf("unexpected conversation payload: %#v", response) } }) } func TestGetConversationsByProjectRequiresProjectAndHandlesFailure(t *testing.T) { t.Run("missing project", func(t *testing.T) { req := httptest.NewRequest(http.MethodGet, "/api/conversations/project", nil) rr := httptest.NewRecorder() newConversationHandler(&conversationServiceStub{}).GetConversationsByProject(rr, req) if rr.Code != http.StatusBadRequest { t.Fatalf("expected 400, got %d", rr.Code) } }) t.Run("service failure", func(t *testing.T) { stub := &conversationServiceStub{ getConversationsByProjectFn: func(projectPath string) ([]*service.Conversation, error) { if projectPath != "team/app" { t.Fatalf("unexpected project path %q", projectPath) } return nil, errors.New("boom") }, } req := httptest.NewRequest(http.MethodGet, "/api/conversations/project?project=team/app", nil) rr := httptest.NewRecorder() newConversationHandler(stub).GetConversationsByProject(rr, req) if rr.Code != http.StatusInternalServerError { t.Fatalf("expected 500, got %d", rr.Code) } var response struct { Error string `json:"error"` } decodeJSONBody(t, rr, &response) if response.Error != "Failed to get project conversations" { t.Fatalf("unexpected error response: %#v", response) } }) }