Files
kandev/apps/backend/internal/workflow/engine/phase8_test.go
T
2026-05-16 20:07:57 +01:00

283 lines
9.2 KiB
Go

package engine
import (
"context"
"errors"
"strings"
"testing"
)
// fakeTaskCreator records every CreateChildTask invocation.
type fakeTaskCreator struct {
calls []struct {
ParentID string
Spec ChildTaskSpec
}
returnedID string
err error
}
func (f *fakeTaskCreator) CreateChildTask(_ context.Context, parentID string, spec ChildTaskSpec) (string, error) {
f.calls = append(f.calls, struct {
ParentID string
Spec ChildTaskSpec
}{ParentID: parentID, Spec: spec})
if f.err != nil {
return "", f.err
}
if f.returnedID != "" {
return f.returnedID, nil
}
return "child-1", nil
}
func newCreateChildTaskInput(spec *CreateChildTaskAction) ActionInput {
return ActionInput{
Trigger: TriggerOnTurnComplete,
State: MachineState{TaskID: "parent-1", SessionID: "sess-1"},
Step: StepSpec{ID: "step-work"},
Action: Action{
Kind: ActionCreateChildTask,
CreateChildTask: spec,
},
OperationID: "op-create-1",
}
}
func TestCreateChildTaskCallback_HappyPath(t *testing.T) {
creator := &fakeTaskCreator{returnedID: "child-1"}
cb := CreateChildTaskCallback{Creator: creator}
spec := &CreateChildTaskAction{
Title: "Fix bug",
Description: "Investigate and fix the panic in /api/foo",
WorkflowID: "wf-kanban",
StepID: "step-1",
AgentProfileID: "profile-claude",
}
if _, err := cb.Execute(context.Background(), newCreateChildTaskInput(spec)); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(creator.calls) != 1 {
t.Fatalf("expected 1 CreateChildTask call, got %d", len(creator.calls))
}
got := creator.calls[0]
if got.ParentID != "parent-1" {
t.Errorf("parent_id = %q, want parent-1", got.ParentID)
}
if got.Spec.Title != "Fix bug" {
t.Errorf("title = %q, want Fix bug", got.Spec.Title)
}
if got.Spec.WorkflowID != "wf-kanban" {
t.Errorf("workflow_id = %q, want wf-kanban", got.Spec.WorkflowID)
}
if got.Spec.StepID != "step-1" {
t.Errorf("step_id = %q, want step-1", got.Spec.StepID)
}
if got.Spec.AgentProfileID != "profile-claude" {
t.Errorf("agent_profile_id = %q, want profile-claude", got.Spec.AgentProfileID)
}
}
func TestCreateChildTaskCallback_RequiresCreator(t *testing.T) {
cb := CreateChildTaskCallback{}
_, err := cb.Execute(context.Background(), newCreateChildTaskInput(&CreateChildTaskAction{Title: "x"}))
if err == nil {
t.Fatalf("expected error when creator is nil")
}
if !errors.Is(err, ErrActionNotYetWired) {
t.Errorf("expected ErrActionNotYetWired, got: %v", err)
}
}
func TestCreateChildTaskCallback_RequiresTitle(t *testing.T) {
creator := &fakeTaskCreator{}
cb := CreateChildTaskCallback{Creator: creator}
_, err := cb.Execute(context.Background(), newCreateChildTaskInput(&CreateChildTaskAction{}))
if err == nil {
t.Fatalf("expected error when title is empty")
}
if !strings.Contains(err.Error(), "title") {
t.Errorf("expected title error, got: %v", err)
}
if len(creator.calls) != 0 {
t.Errorf("expected no CreateChildTask calls when title missing, got %d", len(creator.calls))
}
}
func TestCreateChildTaskCallback_RequiresTaskID(t *testing.T) {
creator := &fakeTaskCreator{}
cb := CreateChildTaskCallback{Creator: creator}
in := newCreateChildTaskInput(&CreateChildTaskAction{Title: "X"})
in.State.TaskID = ""
_, err := cb.Execute(context.Background(), in)
if err == nil {
t.Fatalf("expected error when state.TaskID is empty")
}
}
// fakeWorkflowSwitcher records every SwitchTaskWorkflow invocation.
type fakeWorkflowSwitcher struct {
calls []struct {
TaskID, WorkflowID, StepID string
}
resolvedStep string
err error
}
func (f *fakeWorkflowSwitcher) SwitchTaskWorkflow(_ context.Context, taskID, wfID, stepID string) (string, error) {
f.calls = append(f.calls, struct {
TaskID, WorkflowID, StepID string
}{TaskID: taskID, WorkflowID: wfID, StepID: stepID})
if f.err != nil {
return "", f.err
}
if f.resolvedStep != "" {
return f.resolvedStep, nil
}
if stepID != "" {
return stepID, nil
}
return "first-step", nil
}
// dispatchRecorder captures DispatchTriggerFn invocations for assertions.
type dispatchRecorder struct {
calls []struct {
TaskID, SessionID, OperationID string
Trigger Trigger
}
err error
}
func (d *dispatchRecorder) fn() DispatchTriggerFn {
return func(_ context.Context, taskID, sessionID string, trigger Trigger, opID string) error {
d.calls = append(d.calls, struct {
TaskID, SessionID, OperationID string
Trigger Trigger
}{TaskID: taskID, SessionID: sessionID, OperationID: opID, Trigger: trigger})
return d.err
}
}
func newSwitchWorkflowInput(spec *SwitchWorkflowAction) ActionInput {
return ActionInput{
Trigger: TriggerOnTurnComplete,
State: MachineState{TaskID: "task-1", SessionID: "sess-1"},
Step: StepSpec{ID: "old-step"},
Action: Action{
Kind: ActionSwitchWorkflow,
SwitchWorkflow: spec,
},
OperationID: "op-switch-1",
}
}
func TestSwitchWorkflowCallback_HappyPath(t *testing.T) {
switcher := &fakeWorkflowSwitcher{resolvedStep: "new-first-step"}
rec := &dispatchRecorder{}
cb := SwitchWorkflowCallback{Switcher: switcher, Dispatch: rec.fn()}
spec := &SwitchWorkflowAction{WorkflowID: "wf-office"}
if _, err := cb.Execute(context.Background(), newSwitchWorkflowInput(spec)); err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(switcher.calls) != 1 {
t.Fatalf("expected 1 SwitchTaskWorkflow call, got %d", len(switcher.calls))
}
if switcher.calls[0].TaskID != "task-1" || switcher.calls[0].WorkflowID != "wf-office" {
t.Errorf("switcher call = %+v", switcher.calls[0])
}
// Dispatch fires on_exit (before swap) then on_enter (after swap).
if len(rec.calls) != 2 {
t.Fatalf("expected 2 dispatch calls (on_exit + on_enter), got %d", len(rec.calls))
}
if rec.calls[0].Trigger != TriggerOnExit {
t.Errorf("first dispatch = %s, want on_exit", rec.calls[0].Trigger)
}
if rec.calls[1].Trigger != TriggerOnEnter {
t.Errorf("second dispatch = %s, want on_enter", rec.calls[1].Trigger)
}
if rec.calls[0].TaskID != "task-1" || rec.calls[0].SessionID != "sess-1" {
t.Errorf("dispatch call missing ids: %+v", rec.calls[0])
}
// Idempotency keys must be distinct so on_exit and on_enter both apply.
if rec.calls[0].OperationID == rec.calls[1].OperationID {
t.Errorf("on_exit and on_enter share operation id %q", rec.calls[0].OperationID)
}
}
func TestSwitchWorkflowCallback_RequiresSwitcher(t *testing.T) {
cb := SwitchWorkflowCallback{}
_, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{WorkflowID: "wf"}))
if err == nil || !errors.Is(err, ErrActionNotYetWired) {
t.Fatalf("expected ErrActionNotYetWired, got: %v", err)
}
}
func TestSwitchWorkflowCallback_RequiresWorkflowID(t *testing.T) {
switcher := &fakeWorkflowSwitcher{}
cb := SwitchWorkflowCallback{Switcher: switcher}
_, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{}))
if err == nil {
t.Fatalf("expected error when workflow_id is empty")
}
if len(switcher.calls) != 0 {
t.Errorf("expected no switcher calls when workflow_id empty")
}
}
func TestSwitchWorkflowCallback_OmitsDispatchWhenNil(t *testing.T) {
switcher := &fakeWorkflowSwitcher{resolvedStep: "step"}
cb := SwitchWorkflowCallback{Switcher: switcher, Dispatch: nil}
if _, err := cb.Execute(context.Background(), newSwitchWorkflowInput(&SwitchWorkflowAction{WorkflowID: "wf"})); err != nil {
t.Fatalf("unexpected error with nil dispatch: %v", err)
}
if len(switcher.calls) != 1 {
t.Errorf("expected switch to still happen even without dispatch, got %d calls", len(switcher.calls))
}
}
// TestEngine_CreateChildTaskWiredViaOption asserts the Option/getter
// surface lets a caller probe whether the engine has a TaskCreator wired.
func TestEngine_CreateChildTaskWiredViaOption(t *testing.T) {
creator := &fakeTaskCreator{}
store := &fakeStore{state: MachineState{}, applied: map[string]bool{}}
eng := New(store, MapRegistry{}, WithTaskCreator(creator))
if eng.TaskCreatorAdapter() != creator {
t.Errorf("WithTaskCreator did not wire creator")
}
}
// TestEngine_SwitchWorkflowWiredViaOption asserts the WorkflowSwitcher
// option round-trips through the engine accessor.
func TestEngine_SwitchWorkflowWiredViaOption(t *testing.T) {
switcher := &fakeWorkflowSwitcher{}
store := &fakeStore{state: MachineState{}, applied: map[string]bool{}}
eng := New(store, MapRegistry{}, WithWorkflowSwitcher(switcher))
if eng.WorkflowSwitcherAdapter() != switcher {
t.Errorf("WithWorkflowSwitcher did not wire switcher")
}
}
// TestPayload_ChildSummaryCarriesPRLinks: the engine ChildSummary type
// has been extended in Phase 8 to carry PR URLs joined from
// github_task_prs. Lock the field on the type so subscriber code can
// rely on it.
func TestPayload_ChildSummaryCarriesPRLinks(t *testing.T) {
payload := OnChildrenCompletedPayload{
ChildSummaries: []ChildSummary{
{
TaskID: "child-1",
Status: "completed",
Summary: "Implemented feature",
PRLinks: []string{"https://github.com/owner/repo/pull/42"},
},
},
}
if len(payload.ChildSummaries) != 1 {
t.Fatalf("expected 1 child summary")
}
if got := payload.ChildSummaries[0].PRLinks; len(got) != 1 || got[0] == "" {
t.Errorf("expected PRLinks populated, got %#v", got)
}
}