Files
kandev/apps/backend/internal/task/handlers/task_http_handlers_test.go
T
José Almeida c64e835612 feat: allow moving tasks across workflows (#822)
* feat: allow moving tasks across workflows

* fix: address PR review feedback

* fix: address remaining PR review feedback

* fix: remove unreachable move state event

* fix: map task move conflicts correctly

* fix: address PR review nitpicks

* fix: use neutral task move error message
2026-05-05 14:13:43 +01:00

347 lines
8.7 KiB
Go

package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
)
func newTestLogger(t *testing.T) *logger.Logger {
t.Helper()
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "debug",
Format: "console",
OutputPath: "stdout",
})
if err != nil {
t.Fatalf("Failed to create logger: %v", err)
}
return log
}
func TestHandleSelectedMoveError(t *testing.T) {
gin.SetMode(gin.TestMode)
log := newTestLogger(t)
tests := []struct {
name string
err error
want int
wantBodyContains string
}{
{
name: "not found",
err: errors.New("task not found: task-1"),
want: http.StatusNotFound,
},
{
name: "move conflict",
err: errors.New("task task-1 cannot be moved: task has an active session (running)"),
want: http.StatusConflict,
},
{
name: "bad request validation",
err: errors.New("invalid workflow id"),
want: http.StatusBadRequest,
},
{
name: "internal",
err: errors.New("failed to count target workflow step tasks: database is locked"),
want: http.StatusInternalServerError,
wantBodyContains: "task move failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
handleSelectedMoveError(c, log, tc.err)
assert.Equal(t, tc.want, rec.Code)
if tc.wantBodyContains != "" {
assert.Contains(t, rec.Body.String(), tc.wantBodyContains)
}
})
}
}
type moveTaskConflictRepo struct {
mockRepository
task *models.Task
sessions []*models.TaskSession
}
func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) {
return m.task, nil
}
func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
return m.sessions, nil
}
func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
log := newTestLogger(t)
archivedAt := time.Now().UTC()
tests := []struct {
name string
task *models.Task
sessions []*models.TaskSession
}{
{
name: "archived task",
task: &models.Task{
ID: "task-archived",
WorkspaceID: "workspace-1",
WorkflowID: "wf-source",
WorkflowStepID: "step-source",
ArchivedAt: &archivedAt,
},
},
{
name: "active session",
task: &models.Task{
ID: "task-running",
WorkspaceID: "workspace-1",
WorkflowID: "wf-source",
WorkflowStepID: "step-source",
},
sessions: []*models.TaskSession{{
ID: "session-running",
TaskID: "task-running",
State: models.TaskSessionStateRunning,
}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repo := &moveTaskConflictRepo{task: tc.task, sessions: tc.sessions}
svc := service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
h := &TaskHandlers{service: svc, logger: log}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: tc.task.ID}}
c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+tc.task.ID+"/move", strings.NewReader(`{
"workflow_id": "wf-target",
"workflow_step_id": "step-target",
"position": 0
}`))
c.Request.Header.Set("Content-Type", "application/json")
h.httpMoveTask(c)
assert.Equal(t, http.StatusConflict, rec.Code)
})
}
}
func TestResolveFreshBranchName(t *testing.T) {
tests := []struct {
name string
raw string
taskTitle string
assert func(t *testing.T, got string)
}{
{
name: "uses raw name when provided",
raw: "feature/x",
taskTitle: "ignored",
assert: func(t *testing.T, got string) {
if got != "feature/x" {
t.Fatalf("expected feature/x, got %q", got)
}
},
},
{
name: "trims whitespace from raw name",
raw: " feature/y ",
taskTitle: "ignored",
assert: func(t *testing.T, got string) {
if got != "feature/y" {
t.Fatalf("expected feature/y, got %q", got)
}
},
},
{
name: "derives from title when raw is empty",
raw: "",
taskTitle: "Fix login bug",
assert: func(t *testing.T, got string) {
if !strings.HasPrefix(got, "fix-login-bug_") {
t.Fatalf("expected fix-login-bug_ prefix, got %q", got)
}
},
},
{
name: "title with only special chars falls back to suffix only",
raw: "",
taskTitle: "!!!",
assert: func(t *testing.T, got string) {
// SemanticWorktreeName returns just the suffix (3 chars from
// the alphabet) when the sanitized title is empty.
if len(got) != 3 {
t.Fatalf("expected 3-char suffix, got %q (len %d)", got, len(got))
}
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
tc.assert(t, resolveFreshBranchName(tc.raw, tc.taskTitle))
})
}
}
func TestAssociatePRFromRepoInputs(t *testing.T) {
log := newTestLogger(t)
t.Run("calls callback when repo input contains PR URL", func(t *testing.T) {
var mu sync.Mutex
var called bool
var gotTaskID, gotSessionID, gotPRURL, gotBranch string
h := &TaskHandlers{logger: log}
h.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) {
mu.Lock()
defer mu.Unlock()
called = true
gotTaskID = taskID
gotSessionID = sessionID
gotPRURL = prURL
gotBranch = branch
})
// The callback runs in a goroutine, so we need a channel to sync
done := make(chan struct{})
h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) {
defer close(done)
mu.Lock()
defer mu.Unlock()
called = true
gotTaskID = taskID
gotSessionID = sessionID
gotPRURL = prURL
gotBranch = branch
}
h.associatePRFromRepoInputs("task-1", "session-1", []httpTaskRepositoryInput{
{
GitHubURL: "https://github.com/owner/repo/pull/123",
CheckoutBranch: "feature-branch",
},
})
<-done
mu.Lock()
defer mu.Unlock()
require.True(t, called)
assert.Equal(t, "task-1", gotTaskID)
assert.Equal(t, "session-1", gotSessionID)
assert.Equal(t, "https://github.com/owner/repo/pull/123", gotPRURL)
assert.Equal(t, "feature-branch", gotBranch)
})
t.Run("does not call callback for plain repo URLs", func(t *testing.T) {
called := false
h := &TaskHandlers{logger: log}
h.SetOnTaskCreatedWithPR(func(ctx context.Context, taskID, sessionID, prURL, branch string) {
called = true
})
h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{
{
GitHubURL: "https://github.com/owner/repo",
CheckoutBranch: "main",
},
})
assert.False(t, called)
})
t.Run("does not call callback when no onTaskCreatedWithPR set", func(t *testing.T) {
h := &TaskHandlers{logger: log}
// Should not panic
h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{
{
GitHubURL: "https://github.com/owner/repo/pull/123",
CheckoutBranch: "feature-branch",
},
})
})
t.Run("passes empty session ID when no session created", func(t *testing.T) {
done := make(chan struct{})
var gotSessionID string
h := &TaskHandlers{logger: log}
h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) {
defer close(done)
gotSessionID = sessionID
}
h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{
{
GitHubURL: "https://github.com/owner/repo/pull/456",
CheckoutBranch: "fix-branch",
},
})
<-done
assert.Equal(t, "", gotSessionID)
})
t.Run("only processes first PR URL when multiple repos have PRs", func(t *testing.T) {
var count int
var mu sync.Mutex
done := make(chan struct{})
h := &TaskHandlers{logger: log}
h.onTaskCreatedWithPR = func(ctx context.Context, taskID, sessionID, prURL, branch string) {
defer close(done)
mu.Lock()
defer mu.Unlock()
count++
}
h.associatePRFromRepoInputs("task-1", "", []httpTaskRepositoryInput{
{
GitHubURL: "https://github.com/owner/repo/pull/1",
CheckoutBranch: "branch-1",
},
{
GitHubURL: "https://github.com/owner/repo/pull/2",
CheckoutBranch: "branch-2",
},
})
<-done
mu.Lock()
defer mu.Unlock()
assert.Equal(t, 1, count)
})
}