rdev/internal/handlers/components_test.go
jordan 8282d60c69 feat: implement composable monorepo template system with component architecture
Adds the composable monorepo template system that generates project skeletons
with pluggable components (service, worker, app-react, app-astro, cli).

Key changes:
- Monorepo skeleton templates with shared pkg/, scripts/, and git hooks
- Component templates (service, worker, app-react, app-astro, cli) with
  Dockerfiles, CI steps, and component.yaml manifests
- Component domain model with validation and dependency resolution
- Component handler endpoints for CRUD and composition
- Template provider extended with BuildComposableProject and component assembly
- Deployer extended with composable project deployment support
- Handler timeout constants (TimeoutFastLookup through TimeoutLongRunning)
- envutil package for centralized env var reads with defaults
- api.DecodeJSON helper for standardized request body decoding
- Standardized response helpers (WriteBadRequest, WriteNotFound, etc.)
- Replaced fullstack-app cookbook with composable-app cookbook
- Hardened handler timeouts, logging, and error responses across all handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 19:11:42 -07:00

437 lines
13 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"github.com/orchard9/rdev/internal/domain"
"github.com/orchard9/rdev/internal/port"
)
// mockComponentService is a mock implementation of port.ComponentService for testing.
type mockComponentService struct {
addComponent func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error)
listComponents func(ctx context.Context, projectID string) ([]domain.Component, error)
removeComponent func(ctx context.Context, projectID string, componentPath string) error
}
func (m *mockComponentService) AddComponent(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
if m.addComponent != nil {
return m.addComponent(ctx, projectID, req)
}
return nil, nil
}
func (m *mockComponentService) ListComponents(ctx context.Context, projectID string) ([]domain.Component, error) {
if m.listComponents != nil {
return m.listComponents(ctx, projectID)
}
return nil, nil
}
func (m *mockComponentService) RemoveComponent(ctx context.Context, projectID string, componentPath string) error {
if m.removeComponent != nil {
return m.removeComponent(ctx, projectID, componentPath)
}
return nil
}
func TestComponentsHandler_Add(t *testing.T) {
tests := []struct {
name string
projectID string
body any
setupMock func() *mockComponentService
expectedStatus int
checkResponse func(t *testing.T, body []byte)
}{
{
name: "successful add service component",
projectID: "my-project",
body: AddComponentRequest{
Type: "service",
Name: "auth-api",
},
setupMock: func() *mockComponentService {
return &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
},
}
},
expectedStatus: http.StatusCreated,
checkResponse: func(t *testing.T, body []byte) {
var resp struct {
Data ComponentResponse `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if resp.Data.Type != "service" {
t.Errorf("expected type service, got %s", resp.Data.Type)
}
if resp.Data.Name != "auth-api" {
t.Errorf("expected name auth-api, got %s", resp.Data.Name)
}
if resp.Data.Path != "services/auth-api" {
t.Errorf("expected path services/auth-api, got %s", resp.Data.Path)
}
if resp.Data.Port != 8001 {
t.Errorf("expected port 8001, got %d", resp.Data.Port)
}
},
},
{
name: "missing type",
projectID: "my-project",
body: AddComponentRequest{
Name: "auth-api",
},
setupMock: func() *mockComponentService { return &mockComponentService{} },
expectedStatus: http.StatusBadRequest,
},
{
name: "missing name",
projectID: "my-project",
body: AddComponentRequest{
Type: "service",
},
setupMock: func() *mockComponentService { return &mockComponentService{} },
expectedStatus: http.StatusBadRequest,
},
{
name: "invalid component type",
projectID: "my-project",
body: AddComponentRequest{
Type: "invalid",
Name: "auth-api",
},
setupMock: func() *mockComponentService {
return &mockComponentService{
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
return nil, domain.ErrInvalidComponentType
},
}
},
expectedStatus: http.StatusBadRequest,
},
{
name: "duplicate component",
projectID: "my-project",
body: AddComponentRequest{
Type: "service",
Name: "auth-api",
},
setupMock: func() *mockComponentService {
return &mockComponentService{
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
return nil, domain.ErrDuplicateComponent
},
}
},
expectedStatus: http.StatusConflict,
},
{
name: "project not found",
projectID: "nonexistent",
body: AddComponentRequest{
Type: "service",
Name: "auth-api",
},
setupMock: func() *mockComponentService {
return &mockComponentService{
addComponent: func(ctx context.Context, projectID string, req port.AddComponentRequest) (*domain.Component, error) {
return nil, domain.ErrProjectNotFound
},
}
},
expectedStatus: http.StatusNotFound,
},
{
name: "invalid project ID",
projectID: "123-invalid", // starts with number
body: AddComponentRequest{Type: "service", Name: "auth-api"},
setupMock: func() *mockComponentService { return &mockComponentService{} },
expectedStatus: http.StatusBadRequest,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := tc.setupMock()
handler := NewComponentsHandler(mock, nil)
body, _ := json.Marshal(tc.body)
req := httptest.NewRequest(http.MethodPost, "/projects/"+tc.projectID+"/components", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
// Set up chi routing context
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", tc.projectID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.Add(rec, req)
if rec.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String())
}
if tc.checkResponse != nil {
tc.checkResponse(t, rec.Body.Bytes())
}
})
}
}
func TestComponentsHandler_List(t *testing.T) {
tests := []struct {
name string
projectID string
setupMock func() *mockComponentService
expectedStatus int
checkResponse func(t *testing.T, body []byte)
}{
{
name: "successful list",
projectID: "my-project",
setupMock: func() *mockComponentService {
return &mockComponentService{
listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) {
return []domain.Component{
{
Type: domain.ComponentTypeService,
Name: "auth-api",
Path: "services/auth-api",
Port: 8001,
Template: "service",
Dependencies: []string{},
},
{
Type: domain.ComponentTypeAppAstro,
Name: "landing",
Path: "apps/landing",
Port: 3001,
Template: "app-astro",
Dependencies: []string{},
},
}, nil
},
}
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp struct {
Data struct {
Components []ComponentResponse `json:"components"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Data.Components) != 2 {
t.Errorf("expected 2 components, got %d", len(resp.Data.Components))
}
},
},
{
name: "empty list",
projectID: "my-project",
setupMock: func() *mockComponentService {
return &mockComponentService{
listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) {
return []domain.Component{}, nil
},
}
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp struct {
Data struct {
Components []ComponentResponse `json:"components"`
} `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("failed to unmarshal response: %v", err)
}
if len(resp.Data.Components) != 0 {
t.Errorf("expected 0 components, got %d", len(resp.Data.Components))
}
},
},
{
name: "project not found",
projectID: "nonexistent",
setupMock: func() *mockComponentService {
return &mockComponentService{
listComponents: func(ctx context.Context, projectID string) ([]domain.Component, error) {
return nil, domain.ErrProjectNotFound
},
}
},
expectedStatus: http.StatusNotFound,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := tc.setupMock()
handler := NewComponentsHandler(mock, nil)
req := httptest.NewRequest(http.MethodGet, "/projects/"+tc.projectID+"/components", nil)
// Set up chi routing context
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", tc.projectID)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.List(rec, req)
if rec.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String())
}
if tc.checkResponse != nil {
tc.checkResponse(t, rec.Body.Bytes())
}
})
}
}
func TestComponentsHandler_Remove(t *testing.T) {
tests := []struct {
name string
projectID string
componentPath string
setupMock func() *mockComponentService
expectedStatus int
}{
{
name: "successful remove",
projectID: "my-project",
componentPath: "services/auth-api",
setupMock: func() *mockComponentService {
return &mockComponentService{
removeComponent: func(ctx context.Context, projectID string, componentPath string) error {
return nil
},
}
},
expectedStatus: http.StatusNoContent,
},
{
name: "component not found",
projectID: "my-project",
componentPath: "services/nonexistent",
setupMock: func() *mockComponentService {
return &mockComponentService{
removeComponent: func(ctx context.Context, projectID string, componentPath string) error {
return domain.ErrComponentNotFound
},
}
},
expectedStatus: http.StatusNotFound,
},
{
name: "project not found",
projectID: "nonexistent",
componentPath: "services/auth-api",
setupMock: func() *mockComponentService {
return &mockComponentService{
removeComponent: func(ctx context.Context, projectID string, componentPath string) error {
return domain.ErrProjectNotFound
},
}
},
expectedStatus: http.StatusNotFound,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mock := tc.setupMock()
handler := NewComponentsHandler(mock, nil)
req := httptest.NewRequest(http.MethodDelete, "/projects/"+tc.projectID+"/components/"+tc.componentPath, nil)
// Set up chi routing context
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", tc.projectID)
rctx.URLParams.Add("*", tc.componentPath)
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.Remove(rec, req)
if rec.Code != tc.expectedStatus {
t.Errorf("expected status %d, got %d. Body: %s", tc.expectedStatus, rec.Code, rec.Body.String())
}
})
}
}
func TestComponentsHandler_NilService(t *testing.T) {
handler := NewComponentsHandler(nil, nil)
t.Run("add with nil service", func(t *testing.T) {
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")
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.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
})
t.Run("list with nil service", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/projects/my-project/components", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "my-project")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.List(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
})
t.Run("remove with nil service", func(t *testing.T) {
req := httptest.NewRequest(http.MethodDelete, "/projects/my-project/components/services/auth-api", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("id", "my-project")
rctx.URLParams.Add("*", "services/auth-api")
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx))
rec := httptest.NewRecorder()
handler.Remove(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d", rec.Code)
}
})
}