Files
kandev/apps/backend/internal/workflow/service/service_test.go
T
2026-09-18 00:34:50 +01:00

1124 lines
38 KiB
Go

package service
import (
"context"
"database/sql"
"fmt"
"reflect"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/common/logger"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/workflow/repository"
)
// setupTestService builds a Service against an in-memory SQLite DB. Callers
// needing to observe log output (e.g. via a zaptest observer core) may pass a
// logOverride; otherwise a quiet default logger is used.
func setupTestService(t *testing.T, logOverride ...*logger.Logger) (*Service, *sqlx.DB) {
rawDB, err := sql.Open("sqlite3", ":memory:")
require.NoError(t, err)
// Pin the pool to one connection: every connection to an in-memory SQLite
// DB gets its own database, so a second one (e.g. the async history
// writer goroutine racing a test's read) would not see the schema,
// causing flaky "no such table" failures.
rawDB.SetMaxOpenConns(1)
sqlxDB := sqlx.NewDb(rawDB, "sqlite3")
t.Cleanup(func() { _ = sqlxDB.Close() })
// Create workflows table (normally created by task repo)
_, err = sqlxDB.Exec(`CREATE TABLE IF NOT EXISTS workflows (
id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL DEFAULT '',
workflow_template_id TEXT DEFAULT '', name TEXT NOT NULL,
description TEXT DEFAULT '', created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL
)`)
require.NoError(t, err)
repo, err := repository.NewWithDB(sqlxDB, sqlxDB, nil)
require.NoError(t, err)
log := logOverride
var svcLogger *logger.Logger
if len(log) > 0 {
svcLogger = log[0]
} else {
svcLogger, _ = logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "console"})
}
svc := NewService(repo, svcLogger)
t.Cleanup(func() { _ = svc.Close() })
return svc, sqlxDB
}
func insertWorkflow(t *testing.T, db *sqlx.DB, id, name string) {
now := time.Now().UTC()
_, err := db.Exec("INSERT INTO workflows (id, workspace_id, name, created_at, updated_at) VALUES (?, '', ?, ?, ?)", id, name, now, now)
require.NoError(t, err)
}
// mockWorkflowProvider implements WorkflowProvider with in-memory state for tests.
type mockWorkflowProvider struct {
workflows []*taskmodels.Workflow
getWorkflowCalls int
deleted []string
// forceUpdateWorkflowErr, when set, makes UpdateWorkflow fail without
// mutating state - used to test that a rebind Warn never fires for a
// change that was not actually persisted.
forceUpdateWorkflowErr error
}
func (m *mockWorkflowProvider) ListWorkflows(_ context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) {
var result []*taskmodels.Workflow
for _, wf := range m.workflows {
if wf.WorkspaceID != workspaceID {
continue
}
if !includeHidden && wf.Hidden {
continue
}
result = append(result, wf)
}
return result, nil
}
func (m *mockWorkflowProvider) GetWorkflow(_ context.Context, id string) (*taskmodels.Workflow, error) {
m.getWorkflowCalls++
for _, wf := range m.workflows {
if wf.ID == id {
return wf, nil
}
}
return nil, fmt.Errorf("workflow %s not found", id)
}
func (m *mockWorkflowProvider) CreateWorkflow(_ context.Context, workspaceID, name, description string) (*taskmodels.Workflow, error) {
now := time.Now().UTC()
wf := &taskmodels.Workflow{
ID: "imported-" + name,
WorkspaceID: workspaceID,
Name: name,
Description: description,
CreatedAt: now,
UpdatedAt: now,
}
m.workflows = append(m.workflows, wf)
return wf, nil
}
func (m *mockWorkflowProvider) UpdateWorkflow(_ context.Context, workflow *taskmodels.Workflow) error {
if m.forceUpdateWorkflowErr != nil {
return m.forceUpdateWorkflowErr
}
for i, wf := range m.workflows {
if wf.ID == workflow.ID {
m.workflows[i] = workflow
return nil
}
}
return fmt.Errorf("workflow %s not found", workflow.ID)
}
func (m *mockWorkflowProvider) DeleteWorkflow(_ context.Context, id string) error {
m.deleted = append(m.deleted, id)
for i, wf := range m.workflows {
if wf.ID == id {
m.workflows = append(m.workflows[:i], m.workflows[i+1:]...)
return nil
}
}
return fmt.Errorf("workflow %s not found", id)
}
func (m *mockWorkflowProvider) addWorkflow(id, workspaceID, name string) {
now := time.Now().UTC()
m.workflows = append(m.workflows, &taskmodels.Workflow{
ID: id,
WorkspaceID: workspaceID,
Name: name,
CreatedAt: now,
UpdatedAt: now,
})
}
func createStep(t *testing.T, svc *Service, step *models.WorkflowStep) {
err := svc.CreateStep(context.Background(), step)
require.NoError(t, err)
}
func TestCreateStepRejectsConfigureSessionWithProfile(t *testing.T) {
svc, db := setupTestService(t)
insertWorkflow(t, db, "wf-1", "Test Workflow")
step := &models.WorkflowStep{
WorkflowID: "wf-1",
Name: "Work",
AgentProfileID: "profile-1",
Events: models.StepEvents{OnEnter: []models.OnEnterAction{{
Type: models.OnEnterConfigureSession,
Config: map[string]interface{}{"rules": []interface{}{
map[string]interface{}{"agent_name": "codex", "operation": "keep"},
}}},
}},
}
err := svc.CreateStep(context.Background(), step)
require.ErrorContains(t, err, "cannot be combined with agent_profile_id")
steps, err := svc.repo.ListStepsByWorkflow(context.Background(), "wf-1")
require.NoError(t, err)
require.Empty(t, steps)
}
// TestListTemplates_FiltersHidden verifies that templates marked
// `hidden: true` in their embedded YAML are excluded from the picker shown by
// the create-workflow dialog and the settings UI.
func TestListTemplates_FiltersHidden(t *testing.T) {
svc, _ := setupTestService(t)
templates, err := svc.ListTemplates(context.Background())
require.NoError(t, err)
require.NotEmpty(t, templates, "ListTemplates returned no templates; the assertion below would vacuously pass")
hiddenIDs := []string{"improve-kandev", "office-default", "routine"}
for _, tmpl := range templates {
assert.NotContains(t, hiddenIDs, tmpl.ID, "hidden template must not be returned by ListTemplates")
}
}
func TestGetNextStepByPosition(t *testing.T) {
t.Run("middle step returns next step", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0)
require.NoError(t, err)
assert.NotNil(t, next)
assert.Equal(t, "In Progress", next.Name)
assert.Equal(t, 1, next.Position)
})
t.Run("last step returns nil", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
next, err := svc.GetNextStepByPosition(ctx, "wf-1", 2)
require.NoError(t, err)
assert.Nil(t, next)
})
t.Run("gaps in positions still finds next", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 2})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 5})
next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0)
require.NoError(t, err)
assert.NotNil(t, next)
assert.Equal(t, "Second", next.Name)
assert.Equal(t, 2, next.Position)
next, err = svc.GetNextStepByPosition(ctx, "wf-1", 2)
require.NoError(t, err)
assert.NotNil(t, next)
assert.Equal(t, "Third", next.Name)
assert.Equal(t, 5, next.Position)
})
t.Run("single step returns nil", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Only Step", Position: 0})
next, err := svc.GetNextStepByPosition(ctx, "wf-1", 0)
require.NoError(t, err)
assert.Nil(t, next)
})
}
func TestGetPreviousStepByPosition(t *testing.T) {
t.Run("middle step returns previous step", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 2)
require.NoError(t, err)
assert.NotNil(t, prev)
assert.Equal(t, "In Progress", prev.Name)
assert.Equal(t, 1, prev.Position)
})
t.Run("first step returns nil", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "In Progress", Position: 1})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 0)
require.NoError(t, err)
assert.Nil(t, prev)
})
t.Run("gaps in positions still finds previous", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 2})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 5})
prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 5)
require.NoError(t, err)
assert.NotNil(t, prev)
assert.Equal(t, "Second", prev.Name)
assert.Equal(t, 2, prev.Position)
prev, err = svc.GetPreviousStepByPosition(ctx, "wf-1", 2)
require.NoError(t, err)
assert.NotNil(t, prev)
assert.Equal(t, "First", prev.Name)
assert.Equal(t, 0, prev.Position)
})
t.Run("single step returns nil", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Only Step", Position: 0})
prev, err := svc.GetPreviousStepByPosition(ctx, "wf-1", 0)
require.NoError(t, err)
assert.Nil(t, prev)
})
}
func TestResolveStartStep(t *testing.T) {
t.Run("create step keeps only the latest explicit start step", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First Start", Position: 0, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Latest Start", Position: 1, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
steps, err := svc.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
assert.Equal(t, []string{"Latest Start"}, startStepNames(steps))
})
t.Run("explicit is_start_step returns that step", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Start Here", Position: 1, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
start, err := svc.ResolveStartStep(ctx, "wf-1")
require.NoError(t, err)
assert.NotNil(t, start)
assert.Equal(t, "Start Here", start.Name)
assert.True(t, start.IsStartStep)
})
t.Run("auto_start_agent does not affect fallback (uses position 0)", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{
WorkflowID: "wf-1",
Name: "Auto Start",
Position: 1,
Events: models.StepEvents{
OnEnter: []models.OnEnterAction{
{Type: models.OnEnterAutoStartAgent},
},
},
})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
start, err := svc.ResolveStartStep(ctx, "wf-1")
require.NoError(t, err)
assert.NotNil(t, start)
assert.Equal(t, "Todo", start.Name)
assert.Equal(t, 0, start.Position)
})
t.Run("fallback to first step by position", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "First", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Second", Position: 1})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Third", Position: 2})
start, err := svc.ResolveStartStep(ctx, "wf-1")
require.NoError(t, err)
assert.NotNil(t, start)
assert.Equal(t, "First", start.Name)
assert.Equal(t, 0, start.Position)
})
t.Run("empty workflow returns error", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-empty", "Empty Workflow")
start, err := svc.ResolveStartStep(ctx, "wf-empty")
assert.Error(t, err)
assert.Nil(t, start)
assert.Contains(t, err.Error(), "has no steps")
})
}
func TestResolveFirstStep(t *testing.T) {
t.Run("returns first step by position ignoring is_start_step", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Start Here", Position: 1, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 2})
first, err := svc.ResolveFirstStep(ctx, "wf-1")
require.NoError(t, err)
assert.NotNil(t, first)
assert.Equal(t, "Todo", first.Name)
assert.Equal(t, 0, first.Position)
})
t.Run("empty workflow returns error", func(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-empty", "Empty Workflow")
first, err := svc.ResolveFirstStep(ctx, "wf-empty")
assert.Error(t, err)
assert.Nil(t, first)
assert.Contains(t, err.Error(), "has no steps")
})
}
func TestCreateStepsFromTemplate_RemapsStepIDs(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
// Use the "simple" (Kanban) template which has move_to_step references
err := svc.CreateStepsFromTemplate(ctx, "wf-1", "simple")
require.NoError(t, err)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
require.Len(t, steps, 4)
// Build a map of step name → ID for verification
nameToID := make(map[string]string, len(steps))
for _, s := range steps {
nameToID[s.Name] = s.ID
}
// Backlog's OnTurnComplete should reference the real Review step UUID
backlog := findStepByName(steps, "Backlog")
require.NotNil(t, backlog)
require.Len(t, backlog.Events.OnTurnComplete, 1)
assert.Equal(t, models.OnTurnCompleteMoveToStep, backlog.Events.OnTurnComplete[0].Type)
assert.Equal(t, nameToID["Review"], backlog.Events.OnTurnComplete[0].Config["step_id"])
// In Progress's OnTurnComplete should reference the real Review step UUID
inProgress := findStepByName(steps, "In Progress")
require.NotNil(t, inProgress)
require.Len(t, inProgress.Events.OnTurnComplete, 1)
assert.Equal(t, nameToID["Review"], inProgress.Events.OnTurnComplete[0].Config["step_id"])
// Done's OnTurnStart should reference the real In Progress step UUID
done := findStepByName(steps, "Done")
require.NotNil(t, done)
require.Len(t, done.Events.OnTurnStart, 1)
assert.Equal(t, models.OnTurnStartMoveToStep, done.Events.OnTurnStart[0].Type)
assert.Equal(t, nameToID["In Progress"], done.Events.OnTurnStart[0].Config["step_id"])
}
func TestCreateStepsFromTemplate_PreservesCancelTriggersTurnComplete(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-cancel-defaults", "Cancel defaults")
require.NoError(t, svc.CreateStepsFromTemplate(ctx, "wf-cancel-defaults", "simple"))
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-cancel-defaults")
require.NoError(t, err)
want := map[string]bool{"Backlog": true, "In Progress": true, "Review": false, "Done": false}
for _, step := range steps {
wantValue, ok := want[step.Name]
if !ok {
continue
}
field := reflect.ValueOf(step).Elem().FieldByName("CancelTriggersTurnComplete")
if !field.IsValid() {
t.Fatalf("WorkflowStep is missing CancelTriggersTurnComplete")
}
if got := field.Bool(); got != wantValue {
t.Errorf("created step %q cancel trigger = %t, want %t", step.Name, got, wantValue)
}
}
}
func TestCreateStepsFromTemplate_NormalizesDuplicateStartSteps(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
err := svc.repo.CreateTemplate(ctx, &models.WorkflowTemplate{
ID: "duplicate-starts",
Name: "Duplicate Starts",
Steps: []models.StepDefinition{
{ID: "first", Name: "First Start", Position: 0, Color: "gray", IsStartStep: true},
{ID: "second", Name: "Latest Start", Position: 1, Color: "blue", IsStartStep: true},
{ID: "done", Name: "Done", Position: 2, Color: "green"},
},
})
require.NoError(t, err)
err = svc.CreateStepsFromTemplate(ctx, "wf-1", "duplicate-starts")
require.NoError(t, err)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
assert.Equal(t, []string{"Latest Start"}, startStepNames(steps))
}
func TestCreateStepsFromTemplateRejectsInvalidConfigureSession(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test Workflow")
err := svc.repo.CreateTemplate(ctx, &models.WorkflowTemplate{
ID: "invalid-session-config",
Name: "Invalid Session Config",
Steps: []models.StepDefinition{{
ID: "work",
Name: "Work",
Position: 0,
AgentProfileID: "profile-1",
Events: models.StepEvents{OnEnter: []models.OnEnterAction{{
Type: models.OnEnterConfigureSession,
Config: map[string]interface{}{"rules": []interface{}{
map[string]interface{}{"agent_name": "codex", "operation": "keep"},
}},
}}},
}},
})
require.NoError(t, err)
err = svc.CreateStepsFromTemplate(ctx, "wf-1", "invalid-session-config")
require.ErrorContains(t, err, "cannot be combined with agent_profile_id")
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
require.Empty(t, steps)
}
func findStepByName(steps []*models.WorkflowStep, name string) *models.WorkflowStep {
for _, s := range steps {
if s.Name == name {
return s
}
}
return nil
}
func startStepNames(steps []*models.WorkflowStep) []string {
names := make([]string, 0)
for _, step := range steps {
if step.IsStartStep {
names = append(names, step.Name)
}
}
return names
}
func setupTestServiceWithProvider(t *testing.T) (*Service, *sqlx.DB, *mockWorkflowProvider) {
svc, db := setupTestService(t)
mock := &mockWorkflowProvider{}
svc.SetWorkflowProvider(mock)
return svc, db, mock
}
func TestExportWorkflow(t *testing.T) {
t.Run("exports single workflow with steps", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "", "My Pipeline")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Todo", Position: 0, Color: "gray"})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Done", Position: 1, Color: "green"})
export, err := svc.ExportWorkflow(ctx, "wf-1")
require.NoError(t, err)
assert.Equal(t, models.LegacyExportVersion, export.Version)
assert.Equal(t, models.ExportType, export.Type)
require.Len(t, export.Workflows, 1)
assert.Equal(t, "My Pipeline", export.Workflows[0].Name)
require.Len(t, export.Workflows[0].Steps, 2)
assert.Equal(t, "Todo", export.Workflows[0].Steps[0].Name)
assert.Equal(t, "Done", export.Workflows[0].Steps[1].Name)
})
t.Run("converts step_id refs to positions", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "", "Pipeline")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "A", Position: 0, Color: "gray"})
// Get the created step's ID so we can reference it.
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
stepAID := steps[0].ID
createStep(t, svc, &models.WorkflowStep{
WorkflowID: "wf-1", Name: "B", Position: 1, Color: "blue",
Events: models.StepEvents{
OnTurnComplete: []models.OnTurnCompleteAction{
{Type: models.OnTurnCompleteMoveToStep, Config: map[string]any{"step_id": stepAID}},
},
},
})
export, err := svc.ExportWorkflow(ctx, "wf-1")
require.NoError(t, err)
stepB := export.Workflows[0].Steps[1]
require.Len(t, stepB.Events.OnTurnComplete, 1)
assert.Equal(t, 0, stepB.Events.OnTurnComplete[0].Config["step_position"])
assert.Nil(t, stepB.Events.OnTurnComplete[0].Config["step_id"])
})
}
func TestExportWorkflows(t *testing.T) {
t.Run("exports all workflows for workspace", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "ws-1", "Alpha")
mock.addWorkflow("wf-2", "ws-1", "Beta")
mock.addWorkflow("wf-3", "ws-2", "Other Workspace")
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-1", Name: "Step1", Position: 0})
createStep(t, svc, &models.WorkflowStep{WorkflowID: "wf-2", Name: "Step2", Position: 0})
export, err := svc.ExportWorkflows(ctx, "ws-1", nil)
require.NoError(t, err)
require.Len(t, export.Workflows, 2)
names := []string{export.Workflows[0].Name, export.Workflows[1].Name}
assert.Contains(t, names, "Alpha")
assert.Contains(t, names, "Beta")
})
t.Run("filters to the requested workflow IDs", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "ws-1", "Alpha")
mock.addWorkflow("wf-2", "ws-1", "Beta")
mock.addWorkflow("wf-3", "ws-1", "Gamma")
export, err := svc.ExportWorkflows(ctx, "ws-1", []string{"wf-1", "wf-3"})
require.NoError(t, err)
require.Len(t, export.Workflows, 2)
names := []string{export.Workflows[0].Name, export.Workflows[1].Name}
assert.Contains(t, names, "Alpha")
assert.Contains(t, names, "Gamma")
assert.NotContains(t, names, "Beta")
})
t.Run("empty (non-nil) ID list exports nothing", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "ws-1", "Alpha")
export, err := svc.ExportWorkflows(ctx, "ws-1", []string{})
require.NoError(t, err)
assert.Empty(t, export.Workflows)
})
t.Run("nil ID list omits hidden workflows but explicit IDs include them", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-1", "ws-1", "Alpha")
mock.addWorkflow("hidden-1", "ws-1", "System Flow")
mock.workflows[1].Hidden = true
// Back-compat path (nil) must not leak the hidden system workflow.
all, err := svc.ExportWorkflows(ctx, "ws-1", nil)
require.NoError(t, err)
require.Len(t, all.Workflows, 1)
assert.Equal(t, "Alpha", all.Workflows[0].Name)
// Explicitly requesting a hidden workflow's ID still exports it.
explicit, err := svc.ExportWorkflows(ctx, "ws-1", []string{"hidden-1"})
require.NoError(t, err)
require.Len(t, explicit.Workflows, 1)
assert.Equal(t, "System Flow", explicit.Workflows[0].Name)
})
}
func TestImportWorkflows(t *testing.T) {
t.Run("imports new workflows", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "Imported WF",
Steps: []models.StepPortable{
{Name: "Todo", Position: 0, Color: "gray"},
{Name: "Done", Position: 1, Color: "green"},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
assert.Equal(t, []string{"Imported WF"}, result.Created)
assert.Empty(t, result.Skipped)
// Verify steps were created.
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-Imported WF")
require.NoError(t, err)
assert.Len(t, steps, 2)
})
t.Run("imports workflow-level prompt", func(t *testing.T) {
svc, _, provider := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "Prompted WF",
Prompt: "If the PR is merged or closed, move the Task to Done.",
Steps: []models.StepPortable{
{Name: "Todo", Position: 0, Color: "gray"},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
assert.Equal(t, []string{"Prompted WF"}, result.Created)
wf, err := provider.GetWorkflow(ctx, "imported-Prompted WF")
require.NoError(t, err)
assert.Equal(t, "If the PR is merged or closed, move the Task to Done.", wf.Prompt)
})
t.Run("normalizes duplicate start steps on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "Duplicate Starts",
Steps: []models.StepPortable{
{Name: "First Start", Position: 0, Color: "gray", IsStartStep: true},
{Name: "Latest Start", Position: 1, Color: "blue", IsStartStep: true},
{Name: "Done", Position: 2, Color: "green"},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-Duplicate Starts")
require.NoError(t, err)
assert.Equal(t, []string{"Latest Start"}, startStepNames(steps))
})
t.Run("skips workflows that already exist by name", func(t *testing.T) {
svc, _, mock := setupTestServiceWithProvider(t)
ctx := context.Background()
mock.addWorkflow("wf-existing", "ws-1", "Existing")
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{Name: "Existing", Steps: []models.StepPortable{{Name: "S", Position: 0, Color: "blue"}}},
{Name: "Brand New", Steps: []models.StepPortable{{Name: "S", Position: 0, Color: "red"}}},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
assert.Equal(t, []string{"Brand New"}, result.Created)
assert.Equal(t, []string{"Existing"}, result.Skipped)
})
t.Run("remaps step_position to step_id on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "WithRefs",
Steps: []models.StepPortable{
{Name: "A", Position: 0, Color: "gray"},
{
Name: "B", Position: 1, Color: "blue",
Events: models.StepEvents{
OnTurnComplete: []models.OnTurnCompleteAction{
{Type: models.OnTurnCompleteMoveToStep, Config: map[string]any{"step_position": 0}},
},
},
},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
// Verify the imported step B references step A's new ID.
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-WithRefs")
require.NoError(t, err)
require.Len(t, steps, 2)
nameToID := map[string]string{}
for _, s := range steps {
nameToID[s.Name] = s.ID
}
stepB := findStepByName(steps, "B")
require.NotNil(t, stepB)
require.Len(t, stepB.Events.OnTurnComplete, 1)
assert.Equal(t, nameToID["A"], stepB.Events.OnTurnComplete[0].Config["step_id"])
assert.Nil(t, stepB.Events.OnTurnComplete[0].Config["step_position"])
})
t.Run("preserves show_in_command_panel on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "CmdPanel WF",
Steps: []models.StepPortable{
{Name: "Backlog", Position: 0, Color: "gray", ShowInCommandPanel: false},
{Name: "Active", Position: 1, Color: "blue", ShowInCommandPanel: true},
{Name: "Done", Position: 2, Color: "green", ShowInCommandPanel: false},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-CmdPanel WF")
require.NoError(t, err)
require.Len(t, steps, 3)
assert.False(t, steps[0].ShowInCommandPanel, "Backlog should not show in command panel")
assert.True(t, steps[1].ShowInCommandPanel, "Active should show in command panel")
assert.False(t, steps[2].ShowInCommandPanel, "Done should not show in command panel")
})
t.Run("preserves auto_advance_requires_signal on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "Signal WF",
Steps: []models.StepPortable{
{Name: "Legacy", Position: 0, Color: "gray", AutoAdvanceRequiresSignal: false},
{Name: "Gated", Position: 1, Color: "blue", AutoAdvanceRequiresSignal: true},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-Signal WF")
require.NoError(t, err)
require.Len(t, steps, 2)
assert.False(t, steps[0].AutoAdvanceRequiresSignal, "Legacy step should not require signal")
assert.True(t, steps[1].AutoAdvanceRequiresSignal, "Gated step should require signal")
})
t.Run("rejects invalid export data", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{Version: 99, Type: "wrong"}
_, err := svc.ImportWorkflows(ctx, "ws-1", export)
assert.ErrorContains(t, err, "invalid export data")
})
t.Run("matches agent profiles on import", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
matcher := func(agentName, model, mode, _ string) string {
if agentName == "Claude Code" && model == "opus" {
return "matched-prof-1"
}
return ""
}
svc.SetAgentProfileFuncs(nil, matcher)
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "ProfileWF",
AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code", Model: "opus", Mode: "code"},
Steps: []models.StepPortable{
{
Name: "Dev", Position: 0, Color: "blue",
AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code", Model: "opus"},
},
{
Name: "Review", Position: 1, Color: "green",
AgentProfile: &models.AgentProfilePortable{AgentName: "Unknown Agent"},
},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
// Verify workflow-level agent profile was persisted
imported, err := svc.workflowProvider.GetWorkflow(ctx, "imported-ProfileWF")
require.NoError(t, err)
assert.Equal(t, "matched-prof-1", imported.AgentProfileID, "workflow-level agent profile should be persisted")
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-ProfileWF")
require.NoError(t, err)
require.Len(t, steps, 2)
devStep := findStepByName(steps, "Dev")
require.NotNil(t, devStep)
assert.Equal(t, "matched-prof-1", devStep.AgentProfileID, "should match known profile")
reviewStep := findStepByName(steps, "Review")
require.NotNil(t, reviewStep)
assert.Empty(t, reviewStep.AgentProfileID, "should not match unknown profile")
})
t.Run("skips agent profile matching when no matcher set", func(t *testing.T) {
svc, _, _ := setupTestServiceWithProvider(t)
ctx := context.Background()
export := &models.WorkflowExport{
Version: models.ExportVersion,
Type: models.ExportType,
Workflows: []models.WorkflowPortable{
{
Name: "NoMatcher",
AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code"},
Steps: []models.StepPortable{
{Name: "S", Position: 0, Color: "gray", AgentProfile: &models.AgentProfilePortable{AgentName: "Claude Code"}},
},
},
},
}
result, err := svc.ImportWorkflows(ctx, "ws-1", export)
require.NoError(t, err)
require.Len(t, result.Created, 1)
steps, err := svc.repo.ListStepsByWorkflow(ctx, "imported-NoMatcher")
require.NoError(t, err)
assert.Empty(t, steps[0].AgentProfileID, "no matcher means no profile ID set")
})
}
// --- DeleteStep tests ---
//
// DeleteStep intentionally does NOT auto-reassign tasks off the deleted step: doing
// so through the normal move path would trigger on_exit/on_enter side effects
// (auto-start agents, session hand-offs) as a byproduct of an administrative delete,
// and any failed move would silently leave the step deleted anyway (a partial,
// non-atomic cascade). Affected tasks keep their dangling workflow_step_id and are
// surfaced via the Kanban/Pipeline "Needs Reassignment" fallback for explicit manual
// reassignment through the normal, fully-validated move path.
func TestDeleteStep_RemovesStepWithoutReassigningTasks(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-1", "Test")
createStep(t, svc, &models.WorkflowStep{ID: "start", WorkflowID: "wf-1", Name: "Start", Position: 0, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{ID: "mid", WorkflowID: "wf-1", Name: "Mid", Position: 1})
createStep(t, svc, &models.WorkflowStep{ID: "end", WorkflowID: "wf-1", Name: "End", Position: 2})
require.NoError(t, svc.DeleteStep(ctx, "mid"))
// Deleted step must not exist anymore.
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-1")
require.NoError(t, err)
require.Len(t, steps, 2)
for _, s := range steps {
assert.NotEqual(t, "mid", s.ID, "deleted step must be removed")
}
}
func TestDeleteStep_SucceedsWhenDeletingOnlyStep(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-3", "Test")
createStep(t, svc, &models.WorkflowStep{ID: "only", WorkflowID: "wf-3", Name: "Only", Position: 0})
require.NoError(t, svc.DeleteStep(ctx, "only"))
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-3")
require.NoError(t, err)
assert.Empty(t, steps)
}
// TestDeleteStep_WIPLimitedAutoStartTargetIsUnaffected is a regression test
// requested during maintainer review: deleting a step must not run tasks
// through the normal move path (which could trip a neighboring step's WIP
// limit or trigger its auto-start-agent on_enter action as a side effect of
// an administrative delete). Since DeleteStep no longer reassigns tasks at
// all, this asserts the delete succeeds cleanly and is fully independent of
// what limits/automations are configured on the surviving steps.
func TestDeleteStep_WIPLimitedAutoStartTargetIsUnaffected(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-5", "Test")
createStep(t, svc, &models.WorkflowStep{ID: "start", WorkflowID: "wf-5", Name: "Start", Position: 0, IsStartStep: true})
createStep(t, svc, &models.WorkflowStep{ID: "mid", WorkflowID: "wf-5", Name: "Mid", Position: 1})
createStep(t, svc, &models.WorkflowStep{
ID: "auto-start-limited", WorkflowID: "wf-5", Name: "Auto Start", Position: 2,
WIPLimit: 1,
Events: models.StepEvents{
OnEnter: []models.OnEnterAction{{Type: models.OnEnterAutoStartAgent}},
},
})
require.NoError(t, svc.DeleteStep(ctx, "mid"))
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-5")
require.NoError(t, err)
require.Len(t, steps, 2)
for _, s := range steps {
assert.NotEqual(t, "mid", s.ID, "deleted step must be removed")
if s.ID == "auto-start-limited" {
// The WIP-limited/auto-start step itself must remain untouched.
assert.Equal(t, 1, s.WIPLimit)
}
}
}
func TestDeleteStep_ClearsStepReferences(t *testing.T) {
svc, db := setupTestService(t)
ctx := context.Background()
insertWorkflow(t, db, "wf-4", "Test")
createStep(t, svc, &models.WorkflowStep{ID: "s1", WorkflowID: "wf-4", Name: "S1", Position: 0})
createStep(t, svc, &models.WorkflowStep{ID: "s2", WorkflowID: "wf-4", Name: "S2", Position: 1})
require.NoError(t, svc.DeleteStep(ctx, "s1"))
steps, err := svc.repo.ListStepsByWorkflow(ctx, "wf-4")
require.NoError(t, err)
require.Len(t, steps, 1)
assert.Equal(t, "s2", steps[0].ID)
}