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

884 lines
32 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
"github.com/kandev/kandev/internal/workflow/controller"
"github.com/kandev/kandev/internal/workflow/models"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
)
// fakeWorkflowProvider is the task-domain seam the workflow service reads
// workflows through. It is an ordered in-memory map so export ordering and
// import dedup-by-name are both observable.
type fakeWorkflowProvider struct {
workflows []*taskmodels.Workflow
listErr error
created []string
updated []string
}
func (p *fakeWorkflowProvider) ListWorkflows(
_ context.Context, workspaceID string, includeHidden bool,
) ([]*taskmodels.Workflow, error) {
if p.listErr != nil {
return nil, p.listErr
}
out := make([]*taskmodels.Workflow, 0, len(p.workflows))
for _, wf := range p.workflows {
if wf.WorkspaceID != workspaceID {
continue
}
if wf.Hidden && !includeHidden {
continue
}
out = append(out, wf)
}
return out, nil
}
// GetWorkflow mirrors the production provider's error shape deliberately: the
// task repository reports a missing workflow as a formatted
// `workflow not found: <id>` error, not a sentinel (see
// internal/task/repository/sqlite/workflow.go). Nothing between there and the
// handler classifies it — the workflow service wraps it as
// `failed to get workflow: %w` and httpExportWorkflow answers 500 for any
// error — so returning a sentinel here would make the fake diverge from the
// only behaviour production can produce.
func (p *fakeWorkflowProvider) GetWorkflow(_ context.Context, id string) (*taskmodels.Workflow, error) {
for _, wf := range p.workflows {
if wf.ID == id {
return wf, nil
}
}
return nil, fmt.Errorf("workflow not found: %s", id)
}
func (p *fakeWorkflowProvider) CreateWorkflow(
_ context.Context, workspaceID, name, description string,
) (*taskmodels.Workflow, error) {
wf := &taskmodels.Workflow{
ID: "created-" + name,
WorkspaceID: workspaceID,
Name: name,
Description: description,
}
p.workflows = append(p.workflows, wf)
p.created = append(p.created, name)
return wf, nil
}
func (p *fakeWorkflowProvider) UpdateWorkflow(_ context.Context, workflow *taskmodels.Workflow) error {
p.updated = append(p.updated, workflow.ID)
return nil
}
// fakeWorkspaceProvider resolves workspaces for the read-only guard.
type fakeWorkspaceProvider struct {
workspaces map[string]*taskmodels.Workspace
}
type fakeImportProfileCatalog struct {
profiles []workflowservice.ImportProfileCandidate
}
func (c *fakeImportProfileCatalog) ListEligibleProfiles(context.Context) ([]workflowservice.ImportProfileCandidate, error) {
return append([]workflowservice.ImportProfileCandidate(nil), c.profiles...), nil
}
func (c *fakeImportProfileCatalog) GetEligibleProfile(_ context.Context, id string) (*workflowservice.ImportProfileCandidate, error) {
for _, profile := range c.profiles {
if profile.ID == id {
copy := profile
return &copy, nil
}
}
return nil, workflowservice.ErrImportProfileNotFound
}
func (p *fakeWorkspaceProvider) GetWorkspace(_ context.Context, id string) (*taskmodels.Workspace, error) {
if ws, ok := p.workspaces[id]; ok {
return ws, nil
}
return nil, errors.New("workspace not found")
}
// doRaw issues a request with a raw (possibly non-JSON) body.
func doRaw(t *testing.T, h *workflowHarness, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, strings.NewReader(body))
rec := httptest.NewRecorder()
h.router.ServeHTTP(rec, req)
return rec
}
// requireJSONError asserts the response is exactly {"error": <want>}.
func requireJSONError(t *testing.T, rec *httptest.ResponseRecorder, wantStatus int, wantError string) {
t.Helper()
if rec.Code != wantStatus {
t.Fatalf("status = %d, want %d; body = %s", rec.Code, wantStatus, rec.Body.String())
}
var body map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode body %s: %v", rec.Body.String(), err)
}
if body["error"] != wantError {
t.Fatalf("error = %q, want %q", body["error"], wantError)
}
}
// TestListStepsEndpoints pins the two list surfaces. The workspace variant
// resolves through a join, so a step created under a workflow in another
// workspace must not leak into it.
func TestListStepsEndpoints(t *testing.T) {
h := setupStepRouter(t)
if _, err := h.db.Exec(
`INSERT INTO workflows (id, workspace_id, name, created_at, updated_at)
VALUES ('workflow-1','ws-1','Main',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP),
('workflow-2','ws-2','Other',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil {
t.Fatalf("seed workflows: %v", err)
}
mine := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Backlog", "position": 0,
})
createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-2", "name": "Elsewhere", "position": 0,
})
t.Run("by workflow", func(t *testing.T) {
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/workflows/workflow-1/workflow/steps", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got controller.ListStepsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Steps) != 1 || got.Steps[0].ID != mine.ID || got.Steps[0].Name != "Backlog" {
t.Fatalf("steps = %#v, want only the workflow-1 step", got.Steps)
}
})
t.Run("by workspace", func(t *testing.T) {
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/workspaces/ws-1/workflow-steps", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got controller.ListStepsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Steps) != 1 || got.Steps[0].ID != mine.ID {
t.Fatalf("steps = %#v, want only the ws-1 step", got.Steps)
}
})
t.Run("empty workspace returns an empty list, not an error", func(t *testing.T) {
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/workspaces/ws-none/workflow-steps", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
})
}
// TestGetStepEndpoint pins the single-step body and the not-found mapping.
func TestGetStepEndpoint(t *testing.T) {
h := setupStepRouter(t)
step := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Backlog", "position": 0, "color": "#123456",
})
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/workflow/steps/"+step.ID, nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got controller.GetStepResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Step == nil || got.Step.ID != step.ID || got.Step.Name != "Backlog" || got.Step.Color != "#123456" {
t.Fatalf("step = %#v", got.Step)
}
missing := doJSON(t, h.router, http.MethodGet, "/api/v1/workflow/steps/nope", nil)
requireJSONError(t, missing, http.StatusNotFound, "Step not found")
}
// TestStepMutationValidationRejections pins each rejection code, because the
// handler collapses several service errors into three distinct statuses and
// the kanban UI branches on them.
func TestStepMutationValidationRejections(t *testing.T) {
t.Run("create with malformed json", func(t *testing.T) {
h := setupStepRouter(t)
rec := recordStepEvents(t, h.eventBus)
resp := doRaw(t, h, http.MethodPost, "/api/v1/workflow/steps", "{")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String())
}
if len(rec.events) != 0 {
t.Fatalf("rejected create published %v", rec.subjects())
}
})
t.Run("create rejects explicit null completion policy", func(t *testing.T) {
h := setupStepRouter(t)
rec := recordStepEvents(t, h.eventBus)
resp := doRaw(t, h, http.MethodPost, "/api/v1/workflow/steps", `{"workflow_id":"workflow-1","name":"Null completion","complete_task_on_enter":null}`)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String())
}
if len(rec.events) != 0 {
t.Fatalf("rejected create published %v", rec.subjects())
}
})
t.Run("update rejects explicit null completion policy without mutation", func(t *testing.T) {
h := setupStepRouter(t)
step := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Done", "position": 0,
"complete_task_on_enter": true,
})
rec := recordStepEvents(t, h.eventBus)
resp := doRaw(t, h, http.MethodPut, "/api/v1/workflow/steps/"+step.ID, `{"complete_task_on_enter":null}`)
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String())
}
if len(rec.events) != 0 {
t.Fatalf("rejected update published %v", rec.subjects())
}
stored, err := h.service.GetStep(context.Background(), step.ID)
if err != nil {
t.Fatalf("reload step: %v", err)
}
if !stored.CompleteTaskOnEnter {
t.Fatal("explicit null update changed the saved completion policy")
}
})
t.Run("create without workflow_id or name", func(t *testing.T) {
h := setupStepRouter(t)
rec := recordStepEvents(t, h.eventBus)
for _, body := range []map[string]interface{}{
{"name": "Backlog"},
{"workflow_id": "workflow-1"},
} {
resp := doJSON(t, h.router, http.MethodPost, "/api/v1/workflow/steps", body)
requireJSONError(t, resp, http.StatusBadRequest, "workflow_id and name are required")
}
if len(rec.events) != 0 {
t.Fatalf("rejected create published %v", rec.subjects())
}
})
t.Run("update with a self-referential pull source", func(t *testing.T) {
h := setupStepRouter(t)
step := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Backlog", "position": 0,
})
rec := recordStepEvents(t, h.eventBus)
resp := doJSON(t, h.router, http.MethodPut, "/api/v1/workflow/steps/"+step.ID, map[string]interface{}{
"pull_from_step_id": step.ID,
})
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String())
}
if len(rec.events) != 0 {
t.Fatalf("rejected update published %v", rec.subjects())
}
})
t.Run("update of a missing step is 404", func(t *testing.T) {
h := setupStepRouter(t)
rec := recordStepEvents(t, h.eventBus)
resp := doJSON(t, h.router, http.MethodPut, "/api/v1/workflow/steps/nope", map[string]interface{}{
"name": "Ghost",
})
requireJSONError(t, resp, http.StatusNotFound, "Step not found")
if len(rec.events) != 0 {
t.Fatalf("404 update published %v", rec.subjects())
}
})
t.Run("delete of a missing step is 404 and publishes nothing", func(t *testing.T) {
h := setupStepRouter(t)
rec := recordStepEvents(t, h.eventBus)
resp := doJSON(t, h.router, http.MethodDelete, "/api/v1/workflow/steps/nope", nil)
requireJSONError(t, resp, http.StatusNotFound, "Step not found")
if len(rec.events) != 0 {
t.Fatalf("404 delete published %v", rec.subjects())
}
})
}
// TestStepMutationsBlockedOnReadOnlyWorkflows covers the two read-only causes
// with their own message: a GitHub-synced workflow and a workflow in the
// Improve Kandev workspace. Both are 409 and neither may publish an event or
// touch the database.
func TestStepMutationsBlockedOnReadOnlyWorkflows(t *testing.T) {
cases := []struct {
name string
workflow *taskmodels.Workflow
workspace *taskmodels.Workspace
wantError string
}{
{
name: "github-synced workflow",
workflow: &taskmodels.Workflow{ID: "workflow-1", WorkspaceID: "ws-1", Source: taskmodels.WorkflowSourceGitHub},
workspace: &taskmodels.Workspace{ID: "ws-1", Name: "Normal"},
wantError: "workflow is managed by GitHub sync and is read-only; " +
"edit its definition in the synced repository",
},
{
name: "improve kandev workspace",
workflow: &taskmodels.Workflow{ID: "workflow-1", WorkspaceID: "ws-1"},
workspace: &taskmodels.Workspace{ID: "ws-1", Name: taskmodels.WorkspaceNameImproveKandev},
wantError: "this workspace is managed by Improve Kandev and is read-only",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := setupStepRouter(t)
// Create the step before the guard is wired, so the delete/update
// cases exercise a real row rather than a missing one.
step := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Backlog", "position": 0,
})
h.service.SetWorkflowProvider(&fakeWorkflowProvider{workflows: []*taskmodels.Workflow{tc.workflow}})
h.service.SetWorkspaceProvider(&fakeWorkspaceProvider{
workspaces: map[string]*taskmodels.Workspace{tc.workspace.ID: tc.workspace},
})
rec := recordStepEvents(t, h.eventBus)
create := doJSON(t, h.router, http.MethodPost, "/api/v1/workflow/steps", map[string]interface{}{
"workflow_id": "workflow-1", "name": "Nope", "position": 1,
})
requireJSONError(t, create, http.StatusConflict, tc.wantError)
update := doJSON(t, h.router, http.MethodPut, "/api/v1/workflow/steps/"+step.ID,
map[string]interface{}{"name": "Nope"})
requireJSONError(t, update, http.StatusConflict, tc.wantError)
del := doJSON(t, h.router, http.MethodDelete, "/api/v1/workflow/steps/"+step.ID, nil)
requireJSONError(t, del, http.StatusConflict, tc.wantError)
reorder := doJSON(t, h.router, http.MethodPut,
"/api/v1/workflows/workflow-1/workflow/steps/reorder",
map[string]interface{}{"step_ids": []string{step.ID}})
requireJSONError(t, reorder, http.StatusConflict, tc.wantError)
template := doJSON(t, h.router, http.MethodPost, "/api/v1/workflows/workflow-1/workflow/steps",
map[string]interface{}{"template_id": "whatever"})
requireJSONError(t, template, http.StatusConflict, tc.wantError)
if len(rec.events) != 0 {
t.Fatalf("read-only rejections published %v", rec.subjects())
}
steps, err := h.repo.ListStepsByWorkflow(context.Background(), "workflow-1")
if err != nil {
t.Fatalf("list steps: %v", err)
}
if len(steps) != 1 || steps[0].Name != "Backlog" {
t.Fatalf("read-only rejections mutated the database: %#v", steps)
}
})
}
}
// TestReorderStepsEndpoint pins the persisted positions, not just the 200:
// the handler's own body is a bare success flag, so only the stored order can
// tell a working reorder from a no-op.
func TestReorderStepsEndpoint(t *testing.T) {
h := setupStepRouter(t)
first := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "First", "position": 0,
})
second := createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Second", "position": 1,
})
rec := doJSON(t, h.router, http.MethodPut, "/api/v1/workflows/workflow-1/workflow/steps/reorder",
map[string]interface{}{"step_ids": []string{second.ID, first.ID}})
if rec.Code != http.StatusOK || rec.Body.String() != `{"success":true}` {
t.Fatalf("reorder = %d %s", rec.Code, rec.Body.String())
}
steps, err := h.repo.ListStepsByWorkflow(context.Background(), "workflow-1")
if err != nil {
t.Fatalf("list steps: %v", err)
}
if len(steps) != 2 || steps[0].ID != second.ID || steps[1].ID != first.ID {
t.Fatalf("stored order = %#v, want the reversed order", steps)
}
t.Run("malformed json is 400", func(t *testing.T) {
resp := doRaw(t, h, http.MethodPut, "/api/v1/workflows/workflow-1/workflow/steps/reorder", "{")
if resp.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", resp.Code, resp.Body.String())
}
})
t.Run("an unknown step id in the list is 404 and leaves the order alone", func(t *testing.T) {
resp := doJSON(t, h.router, http.MethodPut, "/api/v1/workflows/workflow-1/workflow/steps/reorder",
map[string]interface{}{"step_ids": []string{"nope"}})
requireJSONError(t, resp, http.StatusNotFound, "Step not found")
steps, err := h.repo.ListStepsByWorkflow(context.Background(), "workflow-1")
if err != nil {
t.Fatalf("list steps: %v", err)
}
if len(steps) != 2 || steps[0].ID != second.ID || steps[1].ID != first.ID {
t.Fatalf("failed reorder changed the order: %#v", steps)
}
})
}
// TestListHistoryEndpointSeparatesDeniedFromBroken guards the comment on
// httpListHistoryBySession: an unauthorized/missing session must read as 404
// so session existence is not leaked, while a genuine read failure must stay a
// 500 rather than being masked as not-found.
func TestListHistoryEndpointSeparatesDeniedFromBroken(t *testing.T) {
t.Run("empty history for an unknown session", func(t *testing.T) {
h := setupStepRouter(t)
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/sessions/session-1/workflow/history", nil)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got controller.ListHistoryResponse
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.History) != 0 {
t.Fatalf("history = %#v, want empty", got.History)
}
})
t.Run("denied session is 404", func(t *testing.T) {
for _, denial := range []error{taskmodels.ErrTaskSessionNotFound, repoerrors.ErrTaskNotFound} {
h := setupStepRouter(t)
h.service.SetSessionAccessChecker(func(context.Context, string) error { return denial })
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/sessions/session-1/workflow/history", nil)
requireJSONError(t, rec, http.StatusNotFound, "session not found")
}
})
t.Run("read failure stays a 500", func(t *testing.T) {
h := setupStepRouter(t)
h.service.SetSessionAccessChecker(func(context.Context, string) error {
return errors.New("database is locked")
})
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/sessions/session-1/workflow/history", nil)
requireJSONError(t, rec, http.StatusInternalServerError, "failed to list history")
})
}
// TestExportWorkflowEndpoints pins the YAML content type and the exported
// document, plus the `ids` filter semantics: absent means "everything
// non-hidden", present restricts to that set and may name a hidden workflow.
func TestExportWorkflowEndpoints(t *testing.T) {
newExportHarness := func(t *testing.T) *workflowHarness {
t.Helper()
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{workflows: []*taskmodels.Workflow{
{ID: "workflow-1", WorkspaceID: "ws-1", Name: "Main", Description: "Primary"},
{ID: "workflow-2", WorkspaceID: "ws-1", Name: "Hidden", Hidden: true},
}})
createStepViaHTTP(t, h.router, map[string]interface{}{
"workflow_id": "workflow-1", "name": "Backlog", "position": 0, "color": "#123456",
})
return h
}
decodeExport := func(t *testing.T, rec *httptest.ResponseRecorder) models.WorkflowExport {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
if ct := rec.Header().Get("Content-Type"); ct != "application/x-yaml" {
t.Fatalf("Content-Type = %q, want application/x-yaml", ct)
}
var export models.WorkflowExport
if err := yaml.Unmarshal(rec.Body.Bytes(), &export); err != nil {
t.Fatalf("decode yaml %s: %v", rec.Body.String(), err)
}
return export
}
t.Run("single workflow export carries its steps", func(t *testing.T) {
h := newExportHarness(t)
export := decodeExport(t, doJSON(t, h.router, http.MethodGet, "/api/v1/workflows/workflow-1/export", nil))
if export.Version != models.LegacyExportVersion || export.Type != models.ExportType {
t.Fatalf("export envelope = %d/%q", export.Version, export.Type)
}
if len(export.Workflows) != 1 || export.Workflows[0].Name != "Main" {
t.Fatalf("workflows = %#v", export.Workflows)
}
steps := export.Workflows[0].Steps
if len(steps) != 1 || steps[0].Name != "Backlog" || steps[0].Color != "#123456" {
t.Fatalf("steps = %#v", steps)
}
})
t.Run("workspace export without ids omits hidden workflows", func(t *testing.T) {
h := newExportHarness(t)
export := decodeExport(t, doJSON(t, h.router, http.MethodGet, "/api/v1/workspaces/ws-1/workflows/export", nil))
if len(export.Workflows) != 1 || export.Workflows[0].Name != "Main" {
t.Fatalf("workflows = %#v, want only the visible one", export.Workflows)
}
})
t.Run("explicit ids restrict the export and may include a hidden workflow", func(t *testing.T) {
h := newExportHarness(t)
export := decodeExport(t, doJSON(t, h.router,
http.MethodGet, "/api/v1/workspaces/ws-1/workflows/export?ids=workflow-2", nil))
if len(export.Workflows) != 1 || export.Workflows[0].Name != "Hidden" {
t.Fatalf("workflows = %#v, want only the named hidden one", export.Workflows)
}
})
t.Run("an ids list that matches nothing exports nothing", func(t *testing.T) {
h := newExportHarness(t)
export := decodeExport(t, doJSON(t, h.router,
http.MethodGet, "/api/v1/workspaces/ws-1/workflows/export?ids=%20,%20", nil))
if len(export.Workflows) != 0 {
t.Fatalf("workflows = %#v, want none", export.Workflows)
}
})
t.Run("provider failure is 500", func(t *testing.T) {
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{listErr: errors.New("database is locked")})
rec := doJSON(t, h.router, http.MethodGet, "/api/v1/workspaces/ws-1/workflows/export", nil)
requireJSONError(t, rec, http.StatusInternalServerError, "Failed to export workflows")
})
}
// TestImportWorkflowsEndpoint covers a successful import (created workflow and
// its persisted steps), dedup-by-name, and the two rejection shapes: unparsable
// YAML and a structurally invalid document.
func TestImportWorkflowsEndpoint(t *testing.T) {
const validYAML = `version: 1
type: kandev_workflow
workflows:
- name: Imported
description: From YAML
steps:
- name: Backlog
position: 0
color: "#111111"
- name: Doing
position: 1
color: "#222222"
`
t.Run("creates the workflow and persists its steps", func(t *testing.T) {
h := setupStepRouter(t)
provider := &fakeWorkflowProvider{}
h.service.SetWorkflowProvider(provider)
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", validYAML)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Created []string `json:"created"`
Skipped []string `json:"skipped"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Created) != 1 || got.Created[0] != "Imported" || len(got.Skipped) != 0 {
t.Fatalf("result = %#v", got)
}
if len(provider.created) != 1 || provider.created[0] != "Imported" {
t.Fatalf("provider created = %v", provider.created)
}
steps, err := h.repo.ListStepsByWorkflow(context.Background(), "created-Imported")
if err != nil {
t.Fatalf("list steps: %v", err)
}
if len(steps) != 2 || steps[0].Name != "Backlog" || steps[1].Name != "Doing" {
t.Fatalf("persisted steps = %#v", steps)
}
})
t.Run("a workflow whose name already exists is skipped, not duplicated", func(t *testing.T) {
h := setupStepRouter(t)
provider := &fakeWorkflowProvider{workflows: []*taskmodels.Workflow{
{ID: "existing", WorkspaceID: "ws-1", Name: "Imported"},
}}
h.service.SetWorkflowProvider(provider)
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", validYAML)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Created []string `json:"created"`
Skipped []string `json:"skipped"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Created) != 0 || len(got.Skipped) != 1 || got.Skipped[0] != "Imported" {
t.Fatalf("result = %#v, want the name skipped", got)
}
if len(provider.created) != 0 {
t.Fatalf("skipped import still created %v", provider.created)
}
})
t.Run("unparsable yaml is 400", func(t *testing.T) {
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{})
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", "version: [1")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "Invalid YAML") {
t.Fatalf("body = %s, want an Invalid YAML message", rec.Body.String())
}
})
t.Run("structurally invalid export is 400 with the validation reason", func(t *testing.T) {
h := setupStepRouter(t)
provider := &fakeWorkflowProvider{}
h.service.SetWorkflowProvider(provider)
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import",
"version: 99\ntype: kandev_workflow\nworkflows: []\n")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body = %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "unsupported export version") {
t.Fatalf("body = %s, want the version reason", rec.Body.String())
}
if len(provider.created) != 0 {
t.Fatalf("invalid import created %v", provider.created)
}
})
}
func TestPreviewImportWorkflowsEndpoint(t *testing.T) {
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{})
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1
type: kandev_workflow
workflows:
- name: Previewed
steps:
- name: Start
position: 0
color: bg-blue-500
`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Skipped []string `json:"skipped"`
Steps []struct {
WorkflowIndex int `json:"workflow_index"`
StepPosition int `json:"step_position"`
WorkflowName string `json:"workflow_name"`
StepName string `json:"step_name"`
} `json:"steps"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Skipped) != 0 || len(got.Steps) != 0 {
t.Fatalf("preview = %#v, want no skipped workflows or profile steps", got)
}
}
func TestImportWorkflowProfileSelectionHTTP(t *testing.T) {
updatedAt := time.Date(2026, 9, 17, 13, 0, 0, 0, time.UTC)
profile := workflowservice.ImportProfileCandidate{
ID: "profile-1", Name: "Codex Default", AgentName: "Codex", Model: "gpt-5", Mode: "full", UpdatedAt: updatedAt,
}
t.Run("preview includes an exact match", func(t *testing.T) {
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{})
h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}})
h.service.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return profile.ID })
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1
type: kandev_workflow
workflows:
- name: Previewed
steps:
- name: Build
position: 0
color: bg-blue-500
agent_profile:
agent_name: Codex
model: gpt-5
mode: full
`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Steps []struct {
MatchedProfile *struct {
ID string `json:"id"`
} `json:"matched_profile"`
} `json:"steps"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got.Steps) != 1 || got.Steps[0].MatchedProfile == nil || got.Steps[0].MatchedProfile.ID != profile.ID {
t.Fatalf("preview = %#v", got)
}
})
t.Run("preview serializes an empty eligible catalog as an array", func(t *testing.T) {
h := setupStepRouter(t)
h.service.SetWorkflowProvider(&fakeWorkflowProvider{})
h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{})
h.service.SetAgentProfileFuncs(nil, func(string, string, string, string) string { return "" })
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", `version: 1
type: kandev_workflow
workflows:
- name: Missing profile
steps:
- name: Build
position: 0
color: bg-blue-500
agent_profile:
agent_name: Missing
model: unavailable
mode: full
`)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Profiles json.RawMessage `json:"profiles"`
Steps []struct {
MatchedProfile *struct{} `json:"matched_profile"`
} `json:"steps"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if string(got.Profiles) != "[]" {
t.Fatalf("profiles = %s, want []", got.Profiles)
}
if len(got.Steps) != 1 || got.Steps[0].MatchedProfile != nil {
t.Fatalf("steps = %#v, want one unmatched step", got.Steps)
}
})
t.Run("stale selection is a structured conflict", func(t *testing.T) {
h := setupStepRouter(t)
provider := &fakeWorkflowProvider{}
h.service.SetWorkflowProvider(provider)
h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}})
body := map[string]any{
"yaml": `version: 1
type: kandev_workflow
workflows:
- name: Selected
steps:
- name: Build
position: 0
color: bg-blue-500
agent_profile:
agent_name: Missing
`,
"step_profile_bindings": []map[string]any{{
"workflow_index": 0,
"step_position": 0,
"requested_profile": map[string]any{
"agent_name": "Missing",
},
"profile_id": profile.ID,
"profile_updated_at": updatedAt.Add(-time.Minute).Format(time.RFC3339),
}},
}
rec := doJSON(t, h.router, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", body)
if rec.Code != http.StatusConflict {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
var got struct {
Code string `json:"code"`
Steps []struct {
Reason string `json:"reason"`
} `json:"steps"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got.Code != "workflow_import_profiles_required" || len(got.Steps) != 1 || got.Steps[0].Reason != workflowservice.ImportProfileReasonChanged {
t.Fatalf("conflict = %#v", got)
}
if len(provider.created) != 0 {
t.Fatalf("conflict created workflows: %v", provider.created)
}
})
t.Run("valid JSON envelope persists the selected ID", func(t *testing.T) {
h := setupStepRouter(t)
provider := &fakeWorkflowProvider{}
h.service.SetWorkflowProvider(provider)
h.service.SetImportProfileCatalog(&fakeImportProfileCatalog{profiles: []workflowservice.ImportProfileCandidate{profile}})
body := map[string]any{
"yaml": `version: 1
type: kandev_workflow
workflows:
- name: Selected
steps:
- name: Build
position: 0
color: bg-blue-500
agent_profile:
agent_name: Missing
`,
"step_profile_bindings": []map[string]any{{
"workflow_index": 0,
"step_position": 0,
"requested_profile": map[string]any{
"agent_name": "Missing",
},
"profile_id": profile.ID,
"profile_updated_at": updatedAt.Format(time.RFC3339),
}},
}
rec := doJSON(t, h.router, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import", body)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
steps, err := h.repo.ListStepsByWorkflow(context.Background(), "created-Selected")
if err != nil {
t.Fatalf("list imported steps: %v", err)
}
if len(steps) != 1 || steps[0].AgentProfileID != profile.ID {
t.Fatalf("steps = %#v", steps)
}
})
t.Run("oversized body is rejected explicitly", func(t *testing.T) {
h := setupStepRouter(t)
rec := doRaw(t, h, http.MethodPost, "/api/v1/workspaces/ws-1/workflows/import/preview", strings.Repeat("x", maxImportSize+1))
if rec.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status = %d, body = %s", rec.Code, rec.Body.String())
}
})
}