rdev/internal/service/sdlc_service_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

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)
}
}