Weeks 1-7 of the template upgrade plan: - pkg/api: typed HTTPError with sentinels, Wrap/WrapMiddleware, Bind, health probes, OpenAPI schema/param builders - skeleton/packages: ui (design tokens, components), layout (DashboardShell), auth (AuthProvider, ProtectedRoute), api-client - skeleton/pkg: httperror, app/handler, app/bind, app/health, auth (JWT/API key middleware) - components/app-nextjs: Next.js 14 App Router template with dashboard, server actions, auth - cookbooks/feature-development.md with test and validation scripts - Handler tests for components, project management, and woodpecker webhook - 3 rounds of code review fixes applied Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
159 lines
4.7 KiB
Go
159 lines
4.7 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/service"
|
|
)
|
|
|
|
func TestComponentsHandler_SetOperationService(t *testing.T) {
|
|
h := NewComponentsHandler(nil, nil)
|
|
|
|
t.Run("sets non-nil service", func(t *testing.T) {
|
|
repo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(repo, slog.Default())
|
|
result := h.SetOperationService(opSvc)
|
|
if h.operationService != opSvc {
|
|
t.Error("expected operation service to be set")
|
|
}
|
|
if result != h {
|
|
t.Error("expected fluent return of handler")
|
|
}
|
|
})
|
|
|
|
t.Run("ignores nil service", func(t *testing.T) {
|
|
repo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(repo, slog.Default())
|
|
h.SetOperationService(opSvc)
|
|
h.SetOperationService(nil)
|
|
if h.operationService != opSvc {
|
|
t.Error("nil should not clear operation service")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestComponentsHandler_AddTracksOperation(t *testing.T) {
|
|
opRepo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(opRepo, slog.Default())
|
|
|
|
mock := &mockComponentService{
|
|
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
|
|
return &domain.Component{
|
|
Type: domain.ComponentTypeService,
|
|
Name: "auth-api",
|
|
Path: "services/auth-api",
|
|
Port: 8001,
|
|
Template: "service",
|
|
Dependencies: []string{},
|
|
}, nil
|
|
},
|
|
}
|
|
|
|
handler := NewComponentsHandler(mock, slog.Default())
|
|
handler.SetOperationService(opSvc)
|
|
|
|
body, _ := json.Marshal(AddComponentRequest{Type: "service", Name: "auth-api"})
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("X-Request-ID", "req-456")
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "my-project")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.Add(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected status 201, got %d. Body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
// Verify operation was created and completed
|
|
if opRepo.count() != 1 {
|
|
t.Fatalf("expected 1 operation, got %d", opRepo.count())
|
|
}
|
|
|
|
// Verify operation status is completed
|
|
for _, op := range opRepo.operations {
|
|
if op.Status != domain.OperationStatusCompleted {
|
|
t.Errorf("expected operation status completed, got %s", op.Status)
|
|
}
|
|
if op.ProjectID != "my-project" {
|
|
t.Errorf("expected project_id my-project, got %s", op.ProjectID)
|
|
}
|
|
if op.Type != domain.OperationTypeComponentAdd {
|
|
t.Errorf("expected operation type component.add, got %s", op.Type)
|
|
}
|
|
if op.RequestID != "req-456" {
|
|
t.Errorf("expected request_id req-456, got %s", op.RequestID)
|
|
}
|
|
}
|
|
|
|
// Verify response contains operation_id
|
|
var resp struct {
|
|
Data map[string]any `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("failed to unmarshal response: %v", err)
|
|
}
|
|
opID, ok := resp.Data["operation_id"]
|
|
if !ok {
|
|
t.Error("response missing operation_id field")
|
|
}
|
|
if opID == "" {
|
|
t.Error("operation_id should not be empty")
|
|
}
|
|
}
|
|
|
|
func TestComponentsHandler_AddFailsOperationOnError(t *testing.T) {
|
|
opRepo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(opRepo, slog.Default())
|
|
|
|
mock := &mockComponentService{
|
|
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
|
|
return nil, domain.ErrInvalidComponentType
|
|
},
|
|
}
|
|
|
|
handler := NewComponentsHandler(mock, slog.Default())
|
|
handler.SetOperationService(opSvc)
|
|
|
|
body, _ := json.Marshal(AddComponentRequest{Type: "invalid", Name: "auth-api"})
|
|
req := httptest.NewRequest(http.MethodPost, "/projects/my-project/components", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
rctx := chi.NewRouteContext()
|
|
rctx.URLParams.Add("id", "my-project")
|
|
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
|
|
|
|
rec := httptest.NewRecorder()
|
|
handler.Add(rec, req)
|
|
|
|
if rec.Code != http.StatusBadRequest {
|
|
t.Fatalf("expected status 400, got %d", rec.Code)
|
|
}
|
|
|
|
// Operation should be created and marked as failed
|
|
if opRepo.count() != 1 {
|
|
t.Fatalf("expected 1 operation, got %d", opRepo.count())
|
|
}
|
|
|
|
for _, op := range opRepo.operations {
|
|
if op.Status != domain.OperationStatusFailed {
|
|
t.Errorf("expected operation status failed, got %s", op.Status)
|
|
}
|
|
if op.Error == "" {
|
|
t.Error("expected error message on failed operation")
|
|
}
|
|
}
|
|
}
|