rdev/internal/service/sdlc_service_test.go
jordan 425ef0f806 feat: add SDLC orchestration - library, CLI, and API integration
Implements deterministic feature lifecycle management for agent-driven
development. Agents use the CLI in pods; operators control via REST API.

Library (internal/sdlc/):
- Feature lifecycle with 10 phases (draft → released)
- Classifier engine with priority-ordered rules
- Artifact tracking with approval workflow
- Task management within features
- YAML-based state persistence

CLI (cmd/sdlc/):
- init, state, next, feature, artifact, task, query commands
- --json flag for machine-readable output
- Runs inside project pods

API (21 endpoints under /projects/{id}/sdlc/):
- State: GET /state, GET /next
- Features: CRUD + transition/block/unblock
- Artifacts: approve/reject per type
- Tasks: add/start/complete/block
- Queries: blocked/ready/needs-approval

Architecture:
- Port: SDLCExecutor interface (internal/port/)
- Adapter: kubectl exec into pods (internal/adapter/kubernetes/)
- Service: pod resolution + logging (internal/service/)
- Handlers: 5 files under 500-line limit (internal/handlers/)

Also includes template upgrades (chassis framework, UI components,
OpenAPI helpers, backend/frontend guides) and component improvements.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 09:57:05 -07:00

375 lines
12 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)
}
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
}
// 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)
}
}