rdev/internal/handlers/queue_test.go
jordan 72d16929ca feat: Implement hexagonal architecture with services, webhooks, queue, and telemetry
Major refactoring to hexagonal (ports & adapters) architecture:

- Add service layer (apikey_service, project_service) for business logic
- Add webhook system with dispatcher and delivery tracking
- Add command queue with priority-based processing
- Add rate limiting with sliding window algorithm
- Add audit logging for command execution
- Add OpenTelemetry integration (traces, metrics, spans)
- Add circuit breaker for fault tolerance
- Add cached repository wrapper for performance
- Add comprehensive validation package
- Add Kubernetes client integration for pod management
- Add database migrations (allowed_ips, audit_log, rate_limiting, queue, webhooks)
- Add network policy and PodDisruptionBudget for k8s
- Remove legacy executor and projects/registry packages
- Untrack secrets.yaml (now managed via envault)
- Add coverage.out to .gitignore
- Add e2e test infrastructure with docker-compose
- Add comprehensive documentation (API, architecture, operations, plans)
- Add golangci-lint config and pre-commit hook

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:57:46 -07:00

536 lines
12 KiB
Go

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