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>
199 lines
6.4 KiB
Go
199 lines
6.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"bytes"
|
|
"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/service"
|
|
)
|
|
|
|
func TestProjectManagementHandler_NilService(t *testing.T) {
|
|
h := NewProjectManagementHandler(nil, slog.Default())
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
|
|
tests := []struct {
|
|
name string
|
|
method string
|
|
path string
|
|
body string
|
|
}{
|
|
{"create", "POST", "/project", `{"name":"test"}`},
|
|
{"list", "GET", "/project", ""},
|
|
{"status", "GET", "/project/test", ""},
|
|
{"delete", "DELETE", "/project/test", ""},
|
|
{"list templates", "GET", "/templates", ""},
|
|
{"list component templates", "GET", "/templates/components", ""},
|
|
{"get template", "GET", "/templates/default", ""},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var req *http.Request
|
|
if tt.body != "" {
|
|
req = httptest.NewRequest(tt.method, tt.path, bytes.NewReader([]byte(tt.body)))
|
|
} else {
|
|
req = httptest.NewRequest(tt.method, tt.path, nil)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("%s: status = %d, want %d", tt.name, rec.Code, http.StatusInternalServerError)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProjectManagementHandler_CreateValidation(t *testing.T) {
|
|
// With nil service, the handler returns 500 before reaching validation.
|
|
// This tests that the nil check takes precedence.
|
|
h := NewProjectManagementHandler(nil, slog.Default())
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
|
|
t.Run("nil service returns 500 even with missing name", func(t *testing.T) {
|
|
body, _ := json.Marshal(CreateRequest{Name: ""})
|
|
req := httptest.NewRequest("POST", "/project", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
})
|
|
|
|
t.Run("nil service returns 500 even with invalid json", func(t *testing.T) {
|
|
req := httptest.NewRequest("POST", "/project", bytes.NewReader([]byte("not json")))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestNewProjectManagementHandler_NilLogger(t *testing.T) {
|
|
h := NewProjectManagementHandler(nil, nil)
|
|
if h.logger == nil {
|
|
t.Error("logger should default to slog.Default() when nil")
|
|
}
|
|
}
|
|
|
|
func TestProjectManagementHandler_SetOperationService(t *testing.T) {
|
|
h := NewProjectManagementHandler(nil, slog.Default())
|
|
|
|
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) // set first
|
|
h.SetOperationService(nil) // should not clear
|
|
if h.operationService != opSvc {
|
|
t.Error("nil should not clear operation service")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestProjectManagementHandler_CreateTracksOperation(t *testing.T) {
|
|
// This test verifies that the Create handler starts and completes operations
|
|
// when an operationService is configured. Since we can't easily mock
|
|
// ProjectInfraService (concrete struct), we test that nil infraService
|
|
// still doesn't panic when operationService is set.
|
|
repo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(repo, slog.Default())
|
|
|
|
h := NewProjectManagementHandler(nil, slog.Default()).
|
|
SetOperationService(opSvc)
|
|
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
|
|
body, _ := json.Marshal(CreateRequest{Name: "test-project"})
|
|
req := httptest.NewRequest("POST", "/project", bytes.NewReader(body))
|
|
req.Header.Set("X-Request-ID", "req-123")
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
// Handler returns 500 because infraService is nil, but operation should NOT
|
|
// have been started because the nil-service check happens before operation tracking.
|
|
if rec.Code != http.StatusInternalServerError {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
|
}
|
|
|
|
// Operation should not be created because infraService nil check comes first
|
|
if repo.count() != 0 {
|
|
t.Errorf("expected no operations (nil service returns early), got %d", repo.count())
|
|
}
|
|
}
|
|
|
|
func TestProjectManagementHandler_OperationIDInResponse(t *testing.T) {
|
|
// Verify the response shape includes operation_id field when it would be set.
|
|
// We test the response structure by examining what Create() writes.
|
|
// Since we can't mock the concrete ProjectInfraService, this is a structural test
|
|
// verifying the handler properly sets the operationService field.
|
|
h := NewProjectManagementHandler(nil, slog.Default())
|
|
if h.operationService != nil {
|
|
t.Error("operationService should be nil by default")
|
|
}
|
|
|
|
repo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(repo, slog.Default())
|
|
h.SetOperationService(opSvc)
|
|
|
|
if h.operationService == nil {
|
|
t.Error("operationService should be set after SetOperationService")
|
|
}
|
|
}
|
|
|
|
func TestProjectManagementHandler_OperationFailsOnError(t *testing.T) {
|
|
// When operationService is set but create fails, the operation should be marked failed.
|
|
// Since nil infraService returns 500 before reaching operation tracking, we verify
|
|
// that operations are not leaked when the handler exits early.
|
|
repo := newMockOperationRepo()
|
|
opSvc := service.NewOperationService(repo, slog.Default())
|
|
|
|
h := &ProjectManagementHandler{
|
|
infraService: nil, // will cause 500
|
|
operationService: opSvc,
|
|
logger: slog.Default(),
|
|
}
|
|
|
|
r := chi.NewRouter()
|
|
r.Post("/project", h.Create)
|
|
|
|
body, _ := json.Marshal(CreateRequest{Name: "fail-project"})
|
|
req := httptest.NewRequest("POST", "/project", bytes.NewReader(body))
|
|
rec := httptest.NewRecorder()
|
|
r.ServeHTTP(rec, req)
|
|
|
|
// The nil infraService check happens before operation tracking starts,
|
|
// so no operation should exist
|
|
if repo.count() != 0 {
|
|
// If an operation was created, verify it was marked as failed
|
|
for _, op := range repo.operations {
|
|
if op.Status != domain.OperationStatusFailed {
|
|
t.Errorf("expected operation status failed, got %s", op.Status)
|
|
}
|
|
}
|
|
}
|
|
}
|