rdev/internal/service/sdlc_service_test.go
jordan 6e8f5821af feat: add artifact pass/fail/needs-fix lifecycle for SDLC execution phases
- Add pass/fail/needs-fix CLI commands to cmd/sdlc/cmd_artifact.go
- Add 3 new methods to SDLCExecutor interface in internal/port
- Implement methods in kubernetes adapter
- Add service methods to SDLCService
- Add HTTP handlers for POST .../artifacts/{type}/pass|fail|needs-fix
- Update 6 skeleton commands to evaluate and set artifact status
- Update test mocks

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 22:14:53 -07:00

443 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
passArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
failArtifactFn func(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error
needsFixArtifactFn 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) PassArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
if m.passArtifactFn != nil {
return m.passArtifactFn(ctx, podName, slug, artType)
}
return nil
}
func (m *mockSDLCExecutor) FailArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
if m.failArtifactFn != nil {
return m.failArtifactFn(ctx, podName, slug, artType)
}
return nil
}
func (m *mockSDLCExecutor) NeedsFixArtifact(ctx context.Context, podName, slug string, artType sdlc.ArtifactType) error {
if m.needsFixArtifactFn != nil {
return m.needsFixArtifactFn(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)
}
}