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