package handlers import ( "bytes" "context" "encoding/json" "net/http" "net/http/httptest" "testing" "time" "github.com/go-chi/chi/v5" "github.com/orchard9/rdev/internal/domain" ) // mockCommandQueue implements port.CommandQueue for testing. type mockCommandQueue struct { commands []*domain.QueuedCommand err error } func (m *mockCommandQueue) Enqueue(ctx context.Context, cmd *domain.QueuedCommand) error { if m.err != nil { return m.err } cmd.ID = domain.QueuedCommandID("queued-cmd-123") m.commands = append(m.commands, cmd) return nil } func (m *mockCommandQueue) Dequeue(ctx context.Context, projectID string) (*domain.QueuedCommand, error) { if m.err != nil { return nil, m.err } for _, cmd := range m.commands { if cmd.ProjectID == projectID && cmd.Status == domain.QueueStatusPending { cmd.Status = domain.QueueStatusRunning return cmd, nil } } return nil, nil } func (m *mockCommandQueue) UpdateStatus(ctx context.Context, cmdID domain.QueuedCommandID, status domain.QueueStatus, result *domain.QueuedCommandResult) error { return m.err } func (m *mockCommandQueue) Cancel(ctx context.Context, cmdID domain.QueuedCommandID) error { if m.err != nil { return m.err } for _, cmd := range m.commands { if cmd.ID == cmdID { if cmd.Status != domain.QueueStatusPending { return domain.ErrCommandNotFound } cmd.Status = domain.QueueStatusCancelled return nil } } return domain.ErrCommandNotFound } func (m *mockCommandQueue) GetByID(ctx context.Context, cmdID domain.QueuedCommandID) (*domain.QueuedCommand, error) { if m.err != nil { return nil, m.err } for _, cmd := range m.commands { if cmd.ID == cmdID { return cmd, nil } } return nil, domain.ErrCommandNotFound } func (m *mockCommandQueue) List(ctx context.Context, projectID string, filters *domain.QueueFilters) ([]*domain.QueuedCommand, error) { if m.err != nil { return nil, m.err } var result []*domain.QueuedCommand for _, cmd := range m.commands { if cmd.ProjectID == projectID { result = append(result, cmd) } } return result, nil } func (m *mockCommandQueue) GetStats(ctx context.Context, projectID string) (*domain.QueueStats, error) { if m.err != nil { return nil, m.err } return &domain.QueueStats{ TotalPending: 1, TotalRunning: 0, TotalCompleted: 0, TotalFailed: 0, TotalCancelled: 0, }, nil } func (m *mockCommandQueue) CleanupOld(ctx context.Context, olderThanDays int) (int64, error) { return 0, m.err } // mockProjectRepo implements port.ProjectRepository for queue handler testing. type mockProjectRepo struct { projects map[domain.ProjectID]*domain.Project } func newMockProjectRepo() *mockProjectRepo { return &mockProjectRepo{ projects: make(map[domain.ProjectID]*domain.Project), } } func (m *mockProjectRepo) List(ctx context.Context) ([]domain.Project, error) { var result []domain.Project for _, p := range m.projects { result = append(result, *p) } return result, nil } func (m *mockProjectRepo) Get(ctx context.Context, id domain.ProjectID) (*domain.Project, error) { if p, ok := m.projects[id]; ok { return p, nil } return nil, domain.ErrProjectNotFound } func (m *mockProjectRepo) Exists(ctx context.Context, id domain.ProjectID) (bool, error) { _, ok := m.projects[id] return ok, nil } func (m *mockProjectRepo) RefreshStatus(ctx context.Context) error { return nil } func (m *mockProjectRepo) Register(ctx context.Context, p *domain.Project) error { m.projects[p.ID] = p return nil } func (m *mockProjectRepo) Unregister(ctx context.Context, id domain.ProjectID) error { delete(m.projects, id) return nil } func TestQueueHandler_Enqueue(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) tests := []struct { name string projectID string body EnqueueRequest wantStatus int }{ { name: "valid claude command", projectID: "proj-1", body: EnqueueRequest{ Command: "explain this code", CommandType: "claude", }, wantStatus: http.StatusCreated, }, { name: "valid shell command", projectID: "proj-1", body: EnqueueRequest{ Command: "ls -la", CommandType: "shell", }, wantStatus: http.StatusCreated, }, { name: "valid git command", projectID: "proj-1", body: EnqueueRequest{ Command: `["status"]`, CommandType: "git", }, wantStatus: http.StatusCreated, }, { name: "invalid command type", projectID: "proj-1", body: EnqueueRequest{ Command: "test", CommandType: "invalid", }, wantStatus: http.StatusBadRequest, }, { name: "empty command", projectID: "proj-1", body: EnqueueRequest{ Command: "", CommandType: "claude", }, wantStatus: http.StatusBadRequest, }, { name: "project not found", projectID: "unknown", body: EnqueueRequest{ Command: "test", CommandType: "claude", }, wantStatus: http.StatusNotFound, }, { name: "dangerous shell command", projectID: "proj-1", body: EnqueueRequest{ Command: "rm -rf /", CommandType: "shell", }, wantStatus: http.StatusBadRequest, }, { name: "invalid git command format", projectID: "proj-1", body: EnqueueRequest{ Command: "not json array", CommandType: "git", }, wantStatus: http.StatusBadRequest, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { queue := &mockCommandQueue{} h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Post("/projects/{id}/queue/", h.Enqueue) body, _ := json.Marshal(tt.body) req := httptest.NewRequest(http.MethodPost, "/projects/"+tt.projectID+"/queue/", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Enqueue() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String()) } }) } } func TestQueueHandler_List(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) queue := &mockCommandQueue{ commands: []*domain.QueuedCommand{ { ID: "cmd-1", ProjectID: "proj-1", Command: "test", CommandType: domain.CommandTypeClaude, Status: domain.QueueStatusPending, CreatedAt: time.Now(), }, }, } tests := []struct { name string projectID string query string wantStatus int wantCount int }{ { name: "list all commands", projectID: "proj-1", query: "", wantStatus: http.StatusOK, wantCount: 1, }, { name: "project not found", projectID: "unknown", query: "", wantStatus: http.StatusNotFound, }, { name: "with limit", projectID: "proj-1", query: "?limit=10", wantStatus: http.StatusOK, wantCount: 1, }, { name: "with offset", projectID: "proj-1", query: "?offset=0", wantStatus: http.StatusOK, wantCount: 1, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/queue/", h.List) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/"+tt.query, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("List() status = %d, want %d", w.Code, tt.wantStatus) } if tt.wantStatus == http.StatusOK { var resp struct { Data ListResponse `json:"data"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("failed to unmarshal response: %v", err) } if len(resp.Data.Commands) != tt.wantCount { t.Errorf("List() count = %d, want %d", len(resp.Data.Commands), tt.wantCount) } } }) } } func TestQueueHandler_GetByID(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) queue := &mockCommandQueue{ commands: []*domain.QueuedCommand{ { ID: "cmd-123", ProjectID: "proj-1", Command: "test", CommandType: domain.CommandTypeClaude, Status: domain.QueueStatusPending, CreatedAt: time.Now(), }, }, } tests := []struct { name string projectID string cmdID string wantStatus int }{ { name: "existing command", projectID: "proj-1", cmdID: "cmd-123", wantStatus: http.StatusOK, }, { name: "non-existent command", projectID: "proj-1", cmdID: "cmd-unknown", wantStatus: http.StatusNotFound, }, { name: "project not found", projectID: "unknown", cmdID: "cmd-123", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/queue/{cmdId}", h.GetByID) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/"+tt.cmdID, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("GetByID() status = %d, want %d", w.Code, tt.wantStatus) } }) } } func TestQueueHandler_Cancel(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) tests := []struct { name string projectID string cmdID string commands []*domain.QueuedCommand wantStatus int }{ { name: "cancel pending command", projectID: "proj-1", cmdID: "cmd-123", commands: []*domain.QueuedCommand{ { ID: "cmd-123", ProjectID: "proj-1", Status: domain.QueueStatusPending, }, }, wantStatus: http.StatusOK, }, { name: "command not found", projectID: "proj-1", cmdID: "cmd-unknown", commands: nil, wantStatus: http.StatusNotFound, }, { name: "project not found", projectID: "unknown", cmdID: "cmd-123", commands: nil, wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { queue := &mockCommandQueue{commands: tt.commands} h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Delete("/projects/{id}/queue/{cmdId}", h.Cancel) req := httptest.NewRequest(http.MethodDelete, "/projects/"+tt.projectID+"/queue/"+tt.cmdID, nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Cancel() status = %d, want %d, body: %s", w.Code, tt.wantStatus, w.Body.String()) } }) } } func TestQueueHandler_Stats(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) queue := &mockCommandQueue{} tests := []struct { name string projectID string wantStatus int }{ { name: "get stats", projectID: "proj-1", wantStatus: http.StatusOK, }, { name: "project not found", projectID: "unknown", wantStatus: http.StatusNotFound, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Get("/projects/{id}/queue/stats", h.Stats) req := httptest.NewRequest(http.MethodGet, "/projects/"+tt.projectID+"/queue/stats", nil) w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != tt.wantStatus { t.Errorf("Stats() status = %d, want %d", w.Code, tt.wantStatus) } }) } } func TestQueueHandler_Enqueue_CommandSizeLimit(t *testing.T) { projectRepo := newMockProjectRepo() projectRepo.Register(context.Background(), &domain.Project{ID: "proj-1", Name: "Test Project"}) queue := &mockCommandQueue{} h := NewQueueHandler(queue, projectRepo) r := chi.NewRouter() r.Post("/projects/{id}/queue/", h.Enqueue) // Create a command that exceeds MaxCommandSize (10KB) largeCommand := make([]byte, MaxCommandSize+1) for i := range largeCommand { largeCommand[i] = 'a' } body := EnqueueRequest{ Command: string(largeCommand), CommandType: "claude", } bodyBytes, _ := json.Marshal(body) req := httptest.NewRequest(http.MethodPost, "/projects/proj-1/queue/", bytes.NewReader(bodyBytes)) req.Header.Set("Content-Type", "application/json") w := httptest.NewRecorder() r.ServeHTTP(w, req) if w.Code != http.StatusBadRequest { t.Errorf("Enqueue() with large command status = %d, want %d", w.Code, http.StatusBadRequest) } }