rdev/internal/handlers/components_operations_test.go
jordan 62460bf098 feat: complete template upgrade - chassis framework, UI library, auth, app-nextjs, OpenAPI, and cookbook
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>
2026-02-02 00:46:51 -07:00

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