rdev/internal/service/sdlc_service_test.go
jordan d69da6d627 feat: add structured logging infrastructure and SDLC extensions
Major changes:
- Add internal/logging package with field constants, context propagation,
  sensitive data auto-redaction, and per-component log levels
- Add worker timeout constants (TimeoutQuickOp, TimeoutHealthCheck, etc.)
- Extend SDLC with callback handlers, generate endpoints, and executor
- Add new cookbook trees for aeries and slackpath progression
- Add skeleton templates for queue, realtime, and microservices
- Add worker component template with async job processing
- Refactor services and handlers to use new logging infrastructure
- Split component.go into component_infra.go and component_listing.go

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

495 lines
16 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)
}
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)
}
}
func TestSDLCService_DualExecutor_UsesPodExecutor(t *testing.T) {
// When project has a pod, should use pod executor
repo := newMockProjectRepo(&domain.Project{
ID: "pod-project",
PodName: "pod-project-pod",
})
podExec := &mockSDLCExecutor{
getStateFn: func(_ context.Context, podName string) (*sdlc.State, error) {
if podName != "pod-project-pod" {
t.Errorf("expected podName 'pod-project-pod', got %s", podName)
}
return &sdlc.State{Version: 1}, nil
},
}
workerExec := &mockSDLCExecutor{
getStateFn: func(_ context.Context, _ string) (*sdlc.State, error) {
t.Error("worker executor should not be called for project with pod")
return nil, nil
},
}
svc := NewSDLCServiceWithWorker(podExec, workerExec, repo, nil)
state, err := svc.GetState(context.Background(), "pod-project")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if state.Version != 1 {
t.Errorf("expected version 1, got %d", state.Version)
}
}
func TestSDLCService_DualExecutor_FallbackToOldBehavior(t *testing.T) {
// When no worker executor configured and project has no pod, should return error
repo := newMockProjectRepo(&domain.Project{
ID: "no-pod-project",
PodName: "", // No pod
})
podExec := &mockSDLCExecutor{}
// Using NewSDLCService (single executor) should fail for project without pod
svc := NewSDLCService(podExec, repo)
_, err := svc.GetState(context.Background(), "no-pod-project")
if err == nil {
t.Error("expected error for project without pod when no worker executor")
}
if !errors.Is(err, domain.ErrProjectNotFound) {
t.Errorf("expected ErrProjectNotFound, got %v", err)
}
}