- 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>
419 lines
14 KiB
Go
419 lines
14 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/orchard9/rdev/internal/domain"
|
|
"github.com/orchard9/rdev/internal/port"
|
|
"github.com/orchard9/rdev/internal/sdlc"
|
|
)
|
|
|
|
// mockSDLCExecutor implements port.SDLCExecutor for testing.
|
|
type mockSDLCExecutor struct {
|
|
getStateFn func(ctx context.Context, podName string) (*sdlc.State, error)
|
|
getNextFn func(ctx context.Context, podName, feature string) (*sdlc.Classification, error)
|
|
listFeaturesFn func(ctx context.Context, podName string) ([]*sdlc.Feature, error)
|
|
getFeatureFn func(ctx context.Context, podName, slug string) (*sdlc.Feature, error)
|
|
createFeatureFn func(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error)
|
|
transitionFeatureFn func(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error
|
|
blockFeatureFn func(ctx context.Context, podName, slug, reason string) error
|
|
unblockFeatureFn func(ctx context.Context, podName, slug string) error
|
|
deleteFeatureFn func(ctx context.Context, podName, slug string) error
|
|
getArtifactStatusFn func(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error)
|
|
approveArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
|
rejectArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
|
|
listTasksFn func(ctx context.Context, podName, slug string) ([]sdlc.Task, error)
|
|
addTaskFn func(ctx context.Context, podName, slug, title string) (*sdlc.Task, error)
|
|
startTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
|
completeTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
|
blockTaskFn func(ctx context.Context, podName, slug, taskID string) error
|
|
queryBlockedFn func(ctx context.Context, podName string) ([]port.BlockedInfo, error)
|
|
queryReadyFn func(ctx context.Context, podName string) ([]port.ReadyInfo, error)
|
|
queryNeedsApprFn func(ctx context.Context, podName string) ([]port.ApprovalInfo, error)
|
|
createBranchFn func(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error)
|
|
getBranchStatusFn func(ctx context.Context, podName, slug string) (*port.BranchStatus, error)
|
|
syncBranchFn func(ctx context.Context, podName, slug string) error
|
|
mergeFeatureFn func(ctx context.Context, podName, slug, strategy string) error
|
|
archiveFeatureFn func(ctx context.Context, podName, slug string) error
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) GetState(ctx context.Context, podName string) (*sdlc.State, error) {
|
|
if m.getStateFn != nil {
|
|
return m.getStateFn(ctx, podName)
|
|
}
|
|
return &sdlc.State{Version: 1}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) GetNext(ctx context.Context, podName, feature string) (*sdlc.Classification, error) {
|
|
if m.getNextFn != nil {
|
|
return m.getNextFn(ctx, podName, feature)
|
|
}
|
|
return &sdlc.Classification{Action: sdlc.ActionIdle}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) ListFeatures(ctx context.Context, podName string) ([]*sdlc.Feature, error) {
|
|
if m.listFeaturesFn != nil {
|
|
return m.listFeaturesFn(ctx, podName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) GetFeature(ctx context.Context, podName, slug string) (*sdlc.Feature, error) {
|
|
if m.getFeatureFn != nil {
|
|
return m.getFeatureFn(ctx, podName, slug)
|
|
}
|
|
return &sdlc.Feature{Slug: slug}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) CreateFeature(ctx context.Context, podName, slug, title string) (*sdlc.Feature, error) {
|
|
if m.createFeatureFn != nil {
|
|
return m.createFeatureFn(ctx, podName, slug, title)
|
|
}
|
|
return &sdlc.Feature{Slug: slug, Title: title}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) TransitionFeature(ctx context.Context, podName, slug string, phase sdlc.FeaturePhase) error {
|
|
if m.transitionFeatureFn != nil {
|
|
return m.transitionFeatureFn(ctx, podName, slug, phase)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) BlockFeature(ctx context.Context, podName, slug, reason string) error {
|
|
if m.blockFeatureFn != nil {
|
|
return m.blockFeatureFn(ctx, podName, slug, reason)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) UnblockFeature(ctx context.Context, podName, slug string) error {
|
|
if m.unblockFeatureFn != nil {
|
|
return m.unblockFeatureFn(ctx, podName, slug)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) DeleteFeature(ctx context.Context, podName, slug string) error {
|
|
if m.deleteFeatureFn != nil {
|
|
return m.deleteFeatureFn(ctx, podName, slug)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) GetArtifactStatus(ctx context.Context, podName, slug string) (map[sdlc.ArtifactType]*sdlc.Artifact, error) {
|
|
if m.getArtifactStatusFn != nil {
|
|
return m.getArtifactStatusFn(ctx, podName, slug)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) ApproveArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
|
if m.approveArtifactFn != nil {
|
|
return m.approveArtifactFn(ctx, podName, slug, artType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) RejectArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
|
|
if m.rejectArtifactFn != nil {
|
|
return m.rejectArtifactFn(ctx, podName, slug, artType)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) ListTasks(ctx context.Context, podName, slug string) ([]sdlc.Task, error) {
|
|
if m.listTasksFn != nil {
|
|
return m.listTasksFn(ctx, podName, slug)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) AddTask(ctx context.Context, podName, slug, title string) (*sdlc.Task, error) {
|
|
if m.addTaskFn != nil {
|
|
return m.addTaskFn(ctx, podName, slug, title)
|
|
}
|
|
return &sdlc.Task{ID: "task-001", Title: title}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) StartTask(ctx context.Context, podName, slug, taskID string) error {
|
|
if m.startTaskFn != nil {
|
|
return m.startTaskFn(ctx, podName, slug, taskID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) CompleteTask(ctx context.Context, podName, slug, taskID string) error {
|
|
if m.completeTaskFn != nil {
|
|
return m.completeTaskFn(ctx, podName, slug, taskID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) BlockTask(ctx context.Context, podName, slug, taskID string) error {
|
|
if m.blockTaskFn != nil {
|
|
return m.blockTaskFn(ctx, podName, slug, taskID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) QueryBlocked(ctx context.Context, podName string) ([]port.BlockedInfo, error) {
|
|
if m.queryBlockedFn != nil {
|
|
return m.queryBlockedFn(ctx, podName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) QueryReady(ctx context.Context, podName string) ([]port.ReadyInfo, error) {
|
|
if m.queryReadyFn != nil {
|
|
return m.queryReadyFn(ctx, podName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) QueryNeedsApproval(ctx context.Context, podName string) ([]port.ApprovalInfo, error) {
|
|
if m.queryNeedsApprFn != nil {
|
|
return m.queryNeedsApprFn(ctx, podName)
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) CreateBranch(ctx context.Context, podName, slug string) (*sdlc.BranchManifest, error) {
|
|
if m.createBranchFn != nil {
|
|
return m.createBranchFn(ctx, podName, slug)
|
|
}
|
|
return &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) GetBranchStatus(ctx context.Context, podName, slug string) (*port.BranchStatus, error) {
|
|
if m.getBranchStatusFn != nil {
|
|
return m.getBranchStatusFn(ctx, podName, slug)
|
|
}
|
|
return &port.BranchStatus{
|
|
Branch: &sdlc.BranchManifest{Name: "feature/" + slug, Feature: slug},
|
|
Checklist: nil,
|
|
Ready: true,
|
|
}, nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) SyncBranch(ctx context.Context, podName, slug string) error {
|
|
if m.syncBranchFn != nil {
|
|
return m.syncBranchFn(ctx, podName, slug)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) MergeFeature(ctx context.Context, podName, slug, strategy string) error {
|
|
if m.mergeFeatureFn != nil {
|
|
return m.mergeFeatureFn(ctx, podName, slug, strategy)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *mockSDLCExecutor) ArchiveFeature(ctx context.Context, podName, slug string) error {
|
|
if m.archiveFeatureFn != nil {
|
|
return m.archiveFeatureFn(ctx, podName, slug)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// mockProjectRepo implements port.ProjectRepository for testing.
|
|
type mockProjectRepo struct {
|
|
projects map[domain.ProjectID]*domain.Project
|
|
}
|
|
|
|
func newMockProjectRepo(projects ...*domain.Project) *mockProjectRepo {
|
|
m := &mockProjectRepo{projects: make(map[domain.ProjectID]*domain.Project)}
|
|
for _, p := range projects {
|
|
m.projects[p.ID] = p
|
|
}
|
|
return m
|
|
}
|
|
|
|
func (m *mockProjectRepo) Get(_ context.Context, id domain.ProjectID) (*domain.Project, error) {
|
|
p, ok := m.projects[id]
|
|
if !ok {
|
|
return nil, domain.ErrProjectNotFound
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func (m *mockProjectRepo) List(_ context.Context) ([]domain.Project, error) { return nil, nil }
|
|
func (m *mockProjectRepo) Exists(_ context.Context, id domain.ProjectID) (bool, error) {
|
|
_, ok := m.projects[id]
|
|
return ok, nil
|
|
}
|
|
func (m *mockProjectRepo) Register(_ context.Context, _ *domain.Project) error { return nil }
|
|
func (m *mockProjectRepo) Unregister(_ context.Context, _ domain.ProjectID) error { return nil }
|
|
func (m *mockProjectRepo) RefreshStatus(_ context.Context) error { return nil }
|
|
|
|
func newTestService(exec port.SDLCExecutor, repo *mockProjectRepo) *SDLCService {
|
|
return NewSDLCService(exec, repo, SDLCServiceConfig{})
|
|
}
|
|
|
|
func TestSDLCService_GetState(t *testing.T) {
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{
|
|
getStateFn: func(_ context.Context, podName string) (*sdlc.State, error) {
|
|
if podName != "myproj-pod" {
|
|
t.Errorf("expected podName myproj-pod, got %s", podName)
|
|
}
|
|
return &sdlc.State{Version: 1}, nil
|
|
},
|
|
}
|
|
svc := newTestService(exec, repo)
|
|
|
|
state, err := svc.GetState(context.Background(), "myproj")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if state.Version != 1 {
|
|
t.Errorf("expected version 1, got %d", state.Version)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_ProjectNotFound(t *testing.T) {
|
|
repo := newMockProjectRepo() // empty
|
|
exec := &mockSDLCExecutor{}
|
|
svc := newTestService(exec, repo)
|
|
|
|
_, err := svc.GetState(context.Background(), "nonexistent")
|
|
if !errors.Is(err, domain.ErrProjectNotFound) {
|
|
t.Errorf("expected ErrProjectNotFound, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_TransitionFeature(t *testing.T) {
|
|
var calledSlug string
|
|
var calledPhase sdlc.FeaturePhase
|
|
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{
|
|
transitionFeatureFn: func(_ context.Context, _ string, slug string, phase sdlc.FeaturePhase) error {
|
|
calledSlug = slug
|
|
calledPhase = phase
|
|
return nil
|
|
},
|
|
}
|
|
svc := newTestService(exec, repo)
|
|
|
|
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseSpecified)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if calledSlug != "auth-flow" {
|
|
t.Errorf("expected slug auth-flow, got %s", calledSlug)
|
|
}
|
|
if calledPhase != sdlc.PhaseSpecified {
|
|
t.Errorf("expected phase specified, got %s", calledPhase)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_TransitionFeature_Error(t *testing.T) {
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{
|
|
transitionFeatureFn: func(_ context.Context, _, _ string, _ sdlc.FeaturePhase) error {
|
|
return sdlc.ErrInvalidTransition
|
|
},
|
|
}
|
|
svc := newTestService(exec, repo)
|
|
|
|
err := svc.TransitionFeature(context.Background(), "myproj", "auth-flow", sdlc.PhaseReview)
|
|
if !errors.Is(err, sdlc.ErrInvalidTransition) {
|
|
t.Errorf("expected ErrInvalidTransition, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_CreateFeature(t *testing.T) {
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{}
|
|
svc := newTestService(exec, repo)
|
|
|
|
f, err := svc.CreateFeature(context.Background(), "myproj", "new-feature", "New Feature")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if f.Slug != "new-feature" {
|
|
t.Errorf("expected slug new-feature, got %s", f.Slug)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_ApproveArtifact(t *testing.T) {
|
|
var calledArtType sdlc.ArtifactType
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{
|
|
approveArtifactFn: func(_ context.Context, _, _ string, artType sdlc.ArtifactType) error {
|
|
calledArtType = artType
|
|
return nil
|
|
},
|
|
}
|
|
svc := newTestService(exec, repo)
|
|
|
|
err := svc.ApproveArtifact(context.Background(), "myproj", "auth-flow", sdlc.ArtifactSpec)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if calledArtType != sdlc.ArtifactSpec {
|
|
t.Errorf("expected artifact type spec, got %s", calledArtType)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_QueryBlocked(t *testing.T) {
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{
|
|
queryBlockedFn: func(_ context.Context, _ string) ([]port.BlockedInfo, error) {
|
|
return []port.BlockedInfo{
|
|
{Slug: "auth", Phase: "implementation", Blockers: []string{"needs API key"}},
|
|
}, nil
|
|
},
|
|
}
|
|
svc := newTestService(exec, repo)
|
|
|
|
blocked, err := svc.QueryBlocked(context.Background(), "myproj")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if len(blocked) != 1 {
|
|
t.Fatalf("expected 1 blocked item, got %d", len(blocked))
|
|
}
|
|
if blocked[0].Slug != "auth" {
|
|
t.Errorf("expected slug auth, got %s", blocked[0].Slug)
|
|
}
|
|
}
|
|
|
|
func TestSDLCService_AddTask(t *testing.T) {
|
|
repo := newMockProjectRepo(&domain.Project{
|
|
ID: "myproj",
|
|
PodName: "myproj-pod",
|
|
})
|
|
exec := &mockSDLCExecutor{}
|
|
svc := newTestService(exec, repo)
|
|
|
|
task, err := svc.AddTask(context.Background(), "myproj", "auth-flow", "Add login form")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if task.Title != "Add login form" {
|
|
t.Errorf("expected title 'Add login form', got %s", task.Title)
|
|
}
|
|
}
|