diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index eec9ef69..3518351d 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -1,6 +1,7 @@ package web import ( + "errors" "net/http" "net/http/httptest" "strings" @@ -10,6 +11,9 @@ import ( "github.com/steveyegge/gastown/internal/activity" ) +// Test error for simulating fetch failures +var errFetchFailed = errors.New("fetch failed") + // MockConvoyFetcher is a mock implementation for testing. type MockConvoyFetcher struct { Convoys []ConvoyRow @@ -189,3 +193,456 @@ func TestConvoyHandler_MultipleConvoys(t *testing.T) { } } } + +// Integration tests for error handling + +func TestConvoyHandler_FetchConvoysError(t *testing.T) { + mock := &MockConvoyFetcher{ + Error: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Status = %d, want %d", w.Code, http.StatusInternalServerError) + } + + body := w.Body.String() + if !strings.Contains(body, "Failed to fetch convoys") { + t.Error("Response should contain error message") + } +} + +// Integration tests for merge queue rendering + +func TestConvoyHandler_MergeQueueRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{ + { + Number: 123, + Repo: "roxas", + Title: "Fix authentication bug", + URL: "https://github.com/test/repo/pull/123", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + { + Number: 456, + Repo: "gastown", + Title: "Add dashboard feature", + URL: "https://github.com/test/repo/pull/456", + CIStatus: "pending", + Mergeable: "pending", + ColorClass: "mq-yellow", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check merge queue section header + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Response should contain merge queue section header") + } + + // Check PR numbers are rendered + if !strings.Contains(body, "#123") { + t.Error("Response should contain PR #123") + } + if !strings.Contains(body, "#456") { + t.Error("Response should contain PR #456") + } + + // Check repo names + if !strings.Contains(body, "roxas") { + t.Error("Response should contain repo 'roxas'") + } + + // Check CI status badges + if !strings.Contains(body, "ci-pass") { + t.Error("Response should contain ci-pass class for passing PR") + } + if !strings.Contains(body, "ci-pending") { + t.Error("Response should contain ci-pending class for pending PR") + } +} + +func TestConvoyHandler_EmptyMergeQueue(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + MergeQueue: []MergeQueueRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Should show empty state for merge queue + if !strings.Contains(body, "No PRs in queue") { + t.Error("Response should show empty merge queue message") + } +} + +// Integration tests for polecat workers rendering + +func TestConvoyHandler_PolecatWorkersRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + Polecats: []PolecatRow{ + { + Name: "dag", + Rig: "roxas", + SessionID: "gt-roxas-dag", + LastActivity: activity.Calculate(time.Now().Add(-30 * time.Second)), + StatusHint: "Running tests...", + }, + { + Name: "nux", + Rig: "roxas", + SessionID: "gt-roxas-nux", + LastActivity: activity.Calculate(time.Now().Add(-5 * time.Minute)), + StatusHint: "Waiting for input", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Check polecat section header + if !strings.Contains(body, "Polecat Workers") { + t.Error("Response should contain polecat workers section header") + } + + // Check polecat names + if !strings.Contains(body, "dag") { + t.Error("Response should contain polecat 'dag'") + } + if !strings.Contains(body, "nux") { + t.Error("Response should contain polecat 'nux'") + } + + // Check rig names + if !strings.Contains(body, "roxas") { + t.Error("Response should contain rig 'roxas'") + } + + // Check status hints + if !strings.Contains(body, "Running tests...") { + t.Error("Response should contain status hint") + } + + // Check activity colors (dag should be green, nux should be yellow/red) + if !strings.Contains(body, "activity-green") { + t.Error("Response should contain activity-green for recent activity") + } +} + +// Integration tests for work status rendering + +func TestConvoyHandler_WorkStatusRendering(t *testing.T) { + tests := []struct { + name string + workStatus string + wantClass string + wantStatusText string + }{ + {"complete status", "complete", "work-complete", "complete"}, + {"active status", "active", "work-active", "active"}, + {"stale status", "stale", "work-stale", "stale"}, + {"stuck status", "stuck", "work-stuck", "stuck"}, + {"waiting status", "waiting", "work-waiting", "waiting"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-test", + Title: "Test Convoy", + Status: "open", + WorkStatus: tt.workStatus, + Progress: "1/2", + Completed: 1, + Total: 2, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check work status class is applied + if !strings.Contains(body, tt.wantClass) { + t.Errorf("Response should contain class %q for work status %q", tt.wantClass, tt.workStatus) + } + + // Check work status text is displayed + if !strings.Contains(body, tt.wantStatusText) { + t.Errorf("Response should contain status text %q", tt.wantStatusText) + } + }) + } +} + +// Integration tests for progress bar rendering + +func TestConvoyHandler_ProgressBarRendering(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-progress", + Title: "Progress Test", + Status: "open", + WorkStatus: "active", + Progress: "3/4", + Completed: 3, + Total: 4, + LastActivity: activity.Calculate(time.Now()), + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check progress text + if !strings.Contains(body, "3/4") { + t.Error("Response should contain progress '3/4'") + } + + // Check progress bar element + if !strings.Contains(body, "progress-bar") { + t.Error("Response should contain progress-bar class") + } + + // Check progress fill with percentage (75%) + if !strings.Contains(body, "progress-fill") { + t.Error("Response should contain progress-fill class") + } + if !strings.Contains(body, "width: 75%") { + t.Error("Response should contain 75% width for 3/4 progress") + } +} + +// Integration test for HTMX auto-refresh + +func TestConvoyHandler_HTMXAutoRefresh(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{}, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + body := w.Body.String() + + // Check htmx attributes for auto-refresh + if !strings.Contains(body, "hx-get") { + t.Error("Response should contain hx-get attribute for HTMX") + } + if !strings.Contains(body, "hx-trigger") { + t.Error("Response should contain hx-trigger attribute for HTMX") + } + if !strings.Contains(body, "every 10s") { + t.Error("Response should contain 'every 10s' trigger interval") + } +} + +// Integration test for full dashboard with all sections + +func TestConvoyHandler_FullDashboard(t *testing.T) { + mock := &MockConvoyFetcher{ + Convoys: []ConvoyRow{ + { + ID: "hq-cv-full", + Title: "Full Test Convoy", + Status: "open", + WorkStatus: "active", + Progress: "2/3", + Completed: 2, + Total: 3, + LastActivity: activity.Calculate(time.Now().Add(-1 * time.Minute)), + }, + }, + MergeQueue: []MergeQueueRow{ + { + Number: 789, + Repo: "testrig", + Title: "Test PR", + CIStatus: "pass", + Mergeable: "ready", + ColorClass: "mq-green", + }, + }, + Polecats: []PolecatRow{ + { + Name: "worker1", + Rig: "testrig", + SessionID: "gt-testrig-worker1", + LastActivity: activity.Calculate(time.Now()), + StatusHint: "Working...", + }, + }, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Verify all three sections are present + if !strings.Contains(body, "Gas Town Convoys") { + t.Error("Response should contain main header") + } + if !strings.Contains(body, "hq-cv-full") { + t.Error("Response should contain convoy data") + } + if !strings.Contains(body, "Refinery Merge Queue") { + t.Error("Response should contain merge queue section") + } + if !strings.Contains(body, "#789") { + t.Error("Response should contain PR data") + } + if !strings.Contains(body, "Polecat Workers") { + t.Error("Response should contain polecat section") + } + if !strings.Contains(body, "worker1") { + t.Error("Response should contain polecat data") + } +} + +// Test that merge queue and polecat errors are non-fatal + +type MockConvoyFetcherWithErrors struct { + Convoys []ConvoyRow + MergeQueueError error + PolecatsError error +} + +func (m *MockConvoyFetcherWithErrors) FetchConvoys() ([]ConvoyRow, error) { + return m.Convoys, nil +} + +func (m *MockConvoyFetcherWithErrors) FetchMergeQueue() ([]MergeQueueRow, error) { + return nil, m.MergeQueueError +} + +func (m *MockConvoyFetcherWithErrors) FetchPolecats() ([]PolecatRow, error) { + return nil, m.PolecatsError +} + +func TestConvoyHandler_NonFatalErrors(t *testing.T) { + mock := &MockConvoyFetcherWithErrors{ + Convoys: []ConvoyRow{ + {ID: "hq-cv-test", Title: "Test", Status: "open", WorkStatus: "active"}, + }, + MergeQueueError: errFetchFailed, + PolecatsError: errFetchFailed, + } + + handler, err := NewConvoyHandler(mock) + if err != nil { + t.Fatalf("NewConvoyHandler() error = %v", err) + } + + req := httptest.NewRequest("GET", "/", nil) + w := httptest.NewRecorder() + + handler.ServeHTTP(w, req) + + // Should still return OK even if merge queue and polecats fail + if w.Code != http.StatusOK { + t.Errorf("Status = %d, want %d (non-fatal errors should not fail request)", w.Code, http.StatusOK) + } + + body := w.Body.String() + + // Convoys should still render + if !strings.Contains(body, "hq-cv-test") { + t.Error("Response should contain convoy data even when other fetches fail") + } +}