- Add ListPipelines/GetPipeline to CIProvider port with Woodpecker adapter
- Add DNS alias endpoints: GET/POST/DELETE /projects/{id}/domains
- Implement worker executor daemon, build executor, and git operations
- Add build service, worker service, and build audit tracking
- Add worker registry with PostgreSQL adapter and migration
- Add multi-provider code agent interface (Claude Code + OpenCode)
- Add create-and-build combo endpoint
- Update landing-page cookbook to reflect all gaps closed
- Fix tech debt: unified validation, auth scopes, error wrapping, slog patterns
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
373 lines
10 KiB
Go
373 lines
10 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
)
|
|
|
|
// mockCodeAgent implements port.CodeAgent for testing.
|
|
type mockCodeAgent struct {
|
|
name string
|
|
provider domain.AgentProvider
|
|
available bool
|
|
capabilities domain.AgentCapabilities
|
|
}
|
|
|
|
func (m *mockCodeAgent) Name() string { return m.name }
|
|
func (m *mockCodeAgent) Provider() domain.AgentProvider { return m.provider }
|
|
func (m *mockCodeAgent) Execute(ctx context.Context, req *domain.AgentRequest, handler domain.AgentEventHandler) (*domain.AgentResult, error) {
|
|
return &domain.AgentResult{}, nil
|
|
}
|
|
func (m *mockCodeAgent) Cancel(ctx context.Context, sessionID string) error { return nil }
|
|
func (m *mockCodeAgent) Capabilities() domain.AgentCapabilities { return m.capabilities }
|
|
func (m *mockCodeAgent) Available(ctx context.Context) bool { return m.available }
|
|
|
|
// mockAgentRegistry implements port.CodeAgentRegistry for testing.
|
|
type mockAgentRegistry struct {
|
|
agents map[domain.AgentProvider]*mockCodeAgent
|
|
defaultAgent domain.AgentProvider
|
|
setDefaultErr error
|
|
}
|
|
|
|
func newMockAgentRegistry() *mockAgentRegistry {
|
|
return &mockAgentRegistry{
|
|
agents: make(map[domain.AgentProvider]*mockCodeAgent),
|
|
}
|
|
}
|
|
|
|
func (m *mockAgentRegistry) Register(agent port.CodeAgent) {
|
|
m.agents[agent.Provider()] = agent.(*mockCodeAgent)
|
|
if m.defaultAgent == "" {
|
|
m.defaultAgent = agent.Provider()
|
|
}
|
|
}
|
|
|
|
// registerAgent is a helper for tests to directly add mockCodeAgents
|
|
func (m *mockAgentRegistry) registerAgent(agent *mockCodeAgent) {
|
|
m.agents[agent.provider] = agent
|
|
if m.defaultAgent == "" {
|
|
m.defaultAgent = agent.provider
|
|
}
|
|
}
|
|
|
|
func (m *mockAgentRegistry) Get(provider domain.AgentProvider) port.CodeAgent {
|
|
agent, ok := m.agents[provider]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
return agent
|
|
}
|
|
|
|
func (m *mockAgentRegistry) Default() port.CodeAgent {
|
|
return m.Get(m.defaultAgent)
|
|
}
|
|
|
|
func (m *mockAgentRegistry) DefaultProvider() domain.AgentProvider {
|
|
return m.defaultAgent
|
|
}
|
|
|
|
func (m *mockAgentRegistry) SetDefault(provider domain.AgentProvider) error {
|
|
if m.setDefaultErr != nil {
|
|
return m.setDefaultErr
|
|
}
|
|
if _, ok := m.agents[provider]; !ok {
|
|
return fmt.Errorf("agent provider %q is not registered", provider)
|
|
}
|
|
m.defaultAgent = provider
|
|
return nil
|
|
}
|
|
|
|
func (m *mockAgentRegistry) Available() []domain.AgentProvider {
|
|
providers := make([]domain.AgentProvider, 0, len(m.agents))
|
|
for p := range m.agents {
|
|
providers = append(providers, p)
|
|
}
|
|
return providers
|
|
}
|
|
|
|
func (m *mockAgentRegistry) AvailableAgents(ctx context.Context) []port.CodeAgent {
|
|
var available []port.CodeAgent
|
|
for _, agent := range m.agents {
|
|
if agent.available {
|
|
available = append(available, agent)
|
|
}
|
|
}
|
|
return available
|
|
}
|
|
|
|
func (m *mockAgentRegistry) Count() int {
|
|
return len(m.agents)
|
|
}
|
|
|
|
func TestAgentsHandler_List(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "Claude Code",
|
|
provider: domain.AgentProviderClaudeCode,
|
|
available: true,
|
|
capabilities: domain.AgentCapabilities{
|
|
Provider: domain.AgentProviderClaudeCode,
|
|
SupportsSessionContinuation: true,
|
|
SupportedModels: []string{"claude-sonnet-4-20250514"},
|
|
DefaultModel: "claude-sonnet-4-20250514",
|
|
},
|
|
})
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "OpenCode",
|
|
provider: domain.AgentProviderOpenCode,
|
|
available: false,
|
|
capabilities: domain.AgentCapabilities{
|
|
Provider: domain.AgentProviderOpenCode,
|
|
SupportsSessionContinuation: true,
|
|
SupportsModelSelection: true,
|
|
SupportedModels: []string{"gpt-4o", "claude-sonnet-4-20250514"},
|
|
DefaultModel: "claude-sonnet-4-20250514",
|
|
},
|
|
})
|
|
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.List(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
// Unwrap from api.Response
|
|
respBody := w.Body.Bytes()
|
|
var apiResp struct {
|
|
Data ListAgentsResponse `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(respBody, &apiResp); err != nil {
|
|
t.Fatalf("failed to decode api response: %v", err)
|
|
}
|
|
|
|
if apiResp.Data.TotalAgents != 2 {
|
|
t.Errorf("expected 2 agents, got %d", apiResp.Data.TotalAgents)
|
|
}
|
|
if apiResp.Data.AvailableCount != 1 {
|
|
t.Errorf("expected 1 available agent, got %d", apiResp.Data.AvailableCount)
|
|
}
|
|
if apiResp.Data.DefaultAgent != string(domain.AgentProviderClaudeCode) {
|
|
t.Errorf("expected default agent to be claude-code, got %s", apiResp.Data.DefaultAgent)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_List_NoRegistry(t *testing.T) {
|
|
handler := NewAgentsHandler(nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.List(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_GetCapabilities(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "Claude Code",
|
|
provider: domain.AgentProviderClaudeCode,
|
|
capabilities: domain.AgentCapabilities{
|
|
Provider: domain.AgentProviderClaudeCode,
|
|
SupportsSessionContinuation: true,
|
|
SupportsStreaming: true,
|
|
SupportedModels: []string{"claude-sonnet-4-20250514"},
|
|
DefaultModel: "claude-sonnet-4-20250514",
|
|
MaxPromptLength: 100000,
|
|
},
|
|
})
|
|
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
// Set up chi router for URL params
|
|
r := chi.NewRouter()
|
|
r.Get("/agents/{provider}", handler.GetCapabilities)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents/claudecode", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
var apiResp struct {
|
|
Data AgentCapabilitiesDTO `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &apiResp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if apiResp.Data.Provider != string(domain.AgentProviderClaudeCode) {
|
|
t.Errorf("expected provider claudecode, got %s", apiResp.Data.Provider)
|
|
}
|
|
if !apiResp.Data.SupportsSessionContinuation {
|
|
t.Error("expected session continuation support")
|
|
}
|
|
if !apiResp.Data.SupportsStreaming {
|
|
t.Error("expected streaming support")
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_GetCapabilities_NotFound(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
r := chi.NewRouter()
|
|
r.Get("/agents/{provider}", handler.GetCapabilities)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents/unknown", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
r.ServeHTTP(w, req)
|
|
|
|
if w.Code != http.StatusNotFound {
|
|
t.Errorf("expected status 404, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_SetDefault(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "Claude Code",
|
|
provider: domain.AgentProviderClaudeCode,
|
|
})
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "OpenCode",
|
|
provider: domain.AgentProviderOpenCode,
|
|
})
|
|
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
body := SetDefaultRequest{Provider: string(domain.AgentProviderOpenCode)}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/default", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.SetDefault(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
|
|
}
|
|
|
|
// Verify default was changed
|
|
if registry.defaultAgent != domain.AgentProviderOpenCode {
|
|
t.Errorf("expected default to be opencode, got %s", registry.defaultAgent)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_SetDefault_InvalidProvider(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "Claude Code",
|
|
provider: domain.AgentProviderClaudeCode,
|
|
})
|
|
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
body := SetDefaultRequest{Provider: "unknown"}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/default", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.SetDefault(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_SetDefault_EmptyProvider(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
body := SetDefaultRequest{Provider: ""}
|
|
bodyBytes, _ := json.Marshal(body)
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/agents/default", bytes.NewReader(bodyBytes))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.SetDefault(w, req)
|
|
|
|
if w.Code != http.StatusBadRequest {
|
|
t.Errorf("expected status 400, got %d", w.Code)
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_Health(t *testing.T) {
|
|
registry := newMockAgentRegistry()
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "Claude Code",
|
|
provider: domain.AgentProviderClaudeCode,
|
|
available: true,
|
|
})
|
|
registry.registerAgent(&mockCodeAgent{
|
|
name: "OpenCode",
|
|
provider: domain.AgentProviderOpenCode,
|
|
available: false,
|
|
})
|
|
|
|
handler := NewAgentsHandler(registry)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents/health", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Health(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
|
|
var apiResp struct {
|
|
Data AgentHealthResponse `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &apiResp); err != nil {
|
|
t.Fatalf("failed to decode response: %v", err)
|
|
}
|
|
|
|
if apiResp.Data.TotalCount != 2 {
|
|
t.Errorf("expected 2 agents, got %d", apiResp.Data.TotalCount)
|
|
}
|
|
if apiResp.Data.HealthyCount != 1 {
|
|
t.Errorf("expected 1 healthy agent, got %d", apiResp.Data.HealthyCount)
|
|
}
|
|
if !apiResp.Data.DefaultHealth {
|
|
t.Error("expected default agent to be healthy")
|
|
}
|
|
}
|
|
|
|
func TestAgentsHandler_Health_NoRegistry(t *testing.T) {
|
|
handler := NewAgentsHandler(nil)
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/agents/health", nil)
|
|
w := httptest.NewRecorder()
|
|
|
|
handler.Health(w, req)
|
|
|
|
if w.Code != http.StatusOK {
|
|
t.Errorf("expected status 200, got %d", w.Code)
|
|
}
|
|
}
|