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>
437 lines
13 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|