rdev/internal/handlers/sdlc_test.go
jordan 56e3f83955 feat: add auth scopes, OpenAPI docs, SDLC guides, and code quality improvements
- Add auth.RequireScope() to all handler routes for proper authorization
- Add SDLC OpenAPI endpoint documentation (state, features, tasks, branches, merge, archive, orchestrator)
- Add SDLC documentation guides (getting-started, cli-reference, api-reference, command-catalog)
- Add artifact_test.go for SDLC artifact coverage
- Add CLAUDE.md rules: auth scopes requirement, error wrapping with %w
- Fix error wrapping to use %w instead of %v throughout codebase
- Improve CLI merge command with conflict detection and resolution
- Fix handler tests to include auth middleware for RequireScope
- Add cookbook tree runner scripts for automated testing

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 13:55:50 -07:00

421 lines
14 KiB
Go

package handlers
import (
"bytes"
"context"
"encoding/json"
"errors"
"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/sdlc"
"github.com/orchard9/rdev/internal/service"
)
// testSDLCExecutor implements port.SDLCExecutor for handler tests.
type testSDLCExecutor struct {
state *sdlc.State
classification *sdlc.Classification
features []*sdlc.Feature
feature *sdlc.Feature
artifacts map[sdlc.ArtifactType]*sdlc.Artifact
tasks []sdlc.Task
task *sdlc.Task
blocked []port.BlockedInfo
ready []port.ReadyInfo
approval []port.ApprovalInfo
err error
}
func (m *testSDLCExecutor) GetState(_ context.Context, _ string) (*sdlc.State, error) {
return m.state, m.err
}
func (m *testSDLCExecutor) GetNext(_ context.Context, _, _ string) (*sdlc.Classification, error) {
return m.classification, m.err
}
func (m *testSDLCExecutor) ListFeatures(_ context.Context, _ string) ([]*sdlc.Feature, error) {
return m.features, m.err
}
func (m *testSDLCExecutor) GetFeature(_ context.Context, _, _ string) (*sdlc.Feature, error) {
return m.feature, m.err
}
func (m *testSDLCExecutor) CreateFeature(_ context.Context, _, slug, title string) (*sdlc.Feature, error) {
if m.err != nil {
return nil, m.err
}
return &sdlc.Feature{Slug: slug, Title: title, Phase: sdlc.PhaseDraft}, nil
}
func (m *testSDLCExecutor) TransitionFeature(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
return m.err
}
func (m *testSDLCExecutor) BlockFeature(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) UnblockFeature(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) DeleteFeature(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) GetArtifactStatus(_ context.Context, _, _ string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
return m.artifacts, m.err
}
func (m *testSDLCExecutor) ApproveArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
return m.err
}
func (m *testSDLCExecutor) RejectArtifact(_ context.Context, _, _ string, _ sdlc.ArtifactType) error {
return m.err
}
func (m *testSDLCExecutor) ListTasks(_ context.Context, _, _ string) ([]sdlc.Task, error) {
return m.tasks, m.err
}
func (m *testSDLCExecutor) AddTask(_ context.Context, _, _, title string) (*sdlc.Task, error) {
if m.err != nil {
return nil, m.err
}
if m.task != nil {
return m.task, nil
}
return &sdlc.Task{ID: "task-001", Title: title, Status: sdlc.TaskPending}, nil
}
func (m *testSDLCExecutor) StartTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) CompleteTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) BlockTask(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) QueryBlocked(_ context.Context, _ string) ([]port.BlockedInfo, error) {
return m.blocked, m.err
}
func (m *testSDLCExecutor) QueryReady(_ context.Context, _ string) ([]port.ReadyInfo, error) {
return m.ready, m.err
}
func (m *testSDLCExecutor) QueryNeedsApproval(_ context.Context, _ string) ([]port.ApprovalInfo, error) {
return m.approval, m.err
}
func (m *testSDLCExecutor) CreateBranch(_ context.Context, _, slug string) (*sdlc.BranchManifest, error) {
if m.err != nil {
return nil, m.err
}
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
}
func (m *testSDLCExecutor) GetBranchStatus(_ context.Context, _, slug string) (*port.BranchStatus, error) {
if m.err != nil {
return nil, m.err
}
return &port.BranchStatus{
Branch: &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug},
Checklist: nil,
Ready: true,
}, nil
}
func (m *testSDLCExecutor) SyncBranch(_ context.Context, _, _ string) error { return m.err }
func (m *testSDLCExecutor) MergeFeature(_ context.Context, _, _, _ string) error { return m.err }
func (m *testSDLCExecutor) ArchiveFeature(_ context.Context, _, _ string) error { return m.err }
// testSDLCProjectRepo implements port.ProjectRepository for handler tests.
type testSDLCProjectRepo struct {
project *domain.Project
}
func (m *testSDLCProjectRepo) Get(_ context.Context, _ domain.ProjectID) (*domain.Project, error) {
if m.project == nil {
return nil, domain.ErrProjectNotFound
}
return m.project, nil
}
func (m *testSDLCProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
func (m *testSDLCProjectRepo) Exists(_ context.Context, _ domain.ProjectID) (bool, error) {
return m.project != nil, nil
}
func (m *testSDLCProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
func (m *testSDLCProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
func (m *testSDLCProjectRepo) RefreshStatus(_ context.Context) error { return nil }
func setupSDLCHandler(exec *testSDLCExecutor) (*SDLCHandler, *chi.Mux) {
repo := &testSDLCProjectRepo{
project: &domain.Project{ID: "test-project", PodName: "test-pod"},
}
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
r.Use(testAdminAuth)
handler.Mount(r)
return handler, r
}
func TestSDLCHandler_GetState(t *testing.T) {
exec := &testSDLCExecutor{
state: &sdlc.State{Version: 1},
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_GetState_NotInitialized(t *testing.T) {
exec := &testSDLCExecutor{
err: sdlc.ErrNotInitialized,
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_GetState_ProjectNotFound(t *testing.T) {
exec := &testSDLCExecutor{}
repo := &testSDLCProjectRepo{project: nil}
svc := service.NewSDLCService(exec, repo, service.SDLCServiceConfig{})
handler := NewSDLCHandler(svc, nil)
r := chi.NewRouter()
r.Use(testAdminAuth)
handler.Mount(r)
req := httptest.NewRequest(http.MethodGet, "/projects/nonexistent/sdlc/state", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_CreateFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_CreateFeature_MissingFields(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_CreateFeature_AlreadyExists(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrFeatureExists}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(CreateFeatureRequest{Slug: "auth-flow", Title: "Auth Flow"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_TransitionFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "specified"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_TransitionFeature_InvalidPhase(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "not-a-phase"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_TransitionFeature_InvalidTransition(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrInvalidTransition}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(TransitionFeatureRequest{Phase: "review"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/transition", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_BlockFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(BlockFeatureRequest{Reason: "needs API key"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_BlockFeature_MissingReason(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(BlockFeatureRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/block", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_DeleteFeature(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/auth-flow", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Errorf("expected status 204, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_DeleteFeature_NotFound(t *testing.T) {
exec := &testSDLCExecutor{err: sdlc.ErrFeatureNotFound}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodDelete, "/projects/test-project/sdlc/features/nonexistent", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Errorf("expected status 404, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_ApproveArtifact(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/spec/approve", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_ApproveArtifact_InvalidType(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/artifacts/invalid/approve", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_AddTask(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(AddTaskRequest{Title: "Add login form"})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusCreated {
t.Errorf("expected status 201, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_AddTask_MissingTitle(t *testing.T) {
exec := &testSDLCExecutor{}
_, router := setupSDLCHandler(exec)
body, _ := json.Marshal(AddTaskRequest{})
req := httptest.NewRequest(http.MethodPost, "/projects/test-project/sdlc/features/auth-flow/tasks", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Errorf("expected status 400, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_QueryBlocked(t *testing.T) {
exec := &testSDLCExecutor{
blocked: []port.BlockedInfo{
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
},
}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/query/blocked", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("expected status 200, got %d: %s", w.Code, w.Body.String())
}
}
func TestSDLCHandler_InternalError(t *testing.T) {
exec := &testSDLCExecutor{err: errors.New("something unexpected")}
_, router := setupSDLCHandler(exec)
req := httptest.NewRequest(http.MethodGet, "/projects/test-project/sdlc/state", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusInternalServerError {
t.Errorf("expected status 500, got %d: %s", w.Code, w.Body.String())
}
}