Files
kandev/apps/backend/internal/mcp/handlers/handlers_test.go
T
2026-05-01 15:18:18 +01:00

413 lines
14 KiB
Go

package handlers
import (
"context"
"encoding/json"
"path/filepath"
"sync"
"testing"
"time"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/orchestrator"
"github.com/kandev/kandev/internal/orchestrator/executor"
"github.com/kandev/kandev/internal/orchestrator/messagequeue"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
ws "github.com/kandev/kandev/pkg/websocket"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestTaskService creates a real task service with a temporary file-backed SQLite DB for integration tests.
// Returns the service and the raw repo (for seeding data).
func newTestTaskService(t *testing.T) (*service.Service, *sqliterepo.Repository) {
t.Helper()
dbConn, err := db.OpenSQLite(filepath.Join(t.TempDir(), "test.db"))
require.NoError(t, err)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB)
require.NoError(t, err)
_, err = worktree.NewSQLiteStore(sqlxDB, sqlxDB)
require.NoError(t, err)
t.Cleanup(func() {
_ = sqlxDB.Close()
_ = cleanup()
})
log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"})
eventBus := bus.NewMemoryEventBus(log)
t.Cleanup(func() { eventBus.Close() })
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,
Reviews: repo,
}, eventBus, log, service.RepositoryDiscoveryConfig{})
return svc, repo
}
func TestHandleCreateTask_MissingTitle(t *testing.T) {
h := &Handlers{}
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"workspace_id": "ws-1",
"workflow_id": "wf-1",
})
resp, err := h.handleCreateTask(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestHandleCreateTask_SubtaskMissingDescription(t *testing.T) {
h := &Handlers{}
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"title": "Fix bug",
"parent_id": "task-parent",
})
resp, err := h.handleCreateTask(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestHandleCreateTask_InvalidPayload(t *testing.T) {
h := &Handlers{}
msg := &ws.Message{
ID: "test-id",
Type: ws.MessageTypeRequest,
Action: ws.ActionMCPCreateTask,
Payload: json.RawMessage(`{invalid`),
}
resp, err := h.handleCreateTask(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeBadRequest)
}
func TestHandleCreateTask_TopLevel_MissingWorkspaceID(t *testing.T) {
h := &Handlers{}
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"title": "New task",
"workflow_id": "wf-1",
})
resp, err := h.handleCreateTask(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
func TestHandleCreateTask_TopLevel_MissingWorkflowID(t *testing.T) {
h := &Handlers{}
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"title": "New task",
"workspace_id": "ws-1",
})
resp, err := h.handleCreateTask(context.Background(), msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}
// mockSessionLauncher captures LaunchSession calls for testing autoStartTask.
type mockSessionLauncher struct {
mu sync.Mutex
req *orchestrator.LaunchSessionRequest
called chan struct{}
}
func newMockSessionLauncher() *mockSessionLauncher {
return &mockSessionLauncher{called: make(chan struct{})}
}
func (m *mockSessionLauncher) LaunchSession(_ context.Context, req *orchestrator.LaunchSessionRequest) (*orchestrator.LaunchSessionResponse, error) {
m.mu.Lock()
m.req = req
m.mu.Unlock()
close(m.called)
return &orchestrator.LaunchSessionResponse{
Success: true,
TaskID: req.TaskID,
SessionID: "session-1",
}, nil
}
func (m *mockSessionLauncher) getRequest() *orchestrator.LaunchSessionRequest {
m.mu.Lock()
defer m.mu.Unlock()
return m.req
}
// The following methods satisfy the SessionLauncher interface but are not used by
// the autoStartTask tests. handleMessageTask tests use a dedicated fakeOrchestrator
// (see message_task_test.go) that exercises these paths.
func (m *mockSessionLauncher) PromptTask(context.Context, string, string, string, string, bool, []v1.MessageAttachment) (*orchestrator.PromptResult, error) {
return nil, nil
}
func (m *mockSessionLauncher) StartCreatedSession(context.Context, string, string, string, string, bool, bool, []v1.MessageAttachment) (*executor.TaskExecution, error) {
return nil, nil
}
func (m *mockSessionLauncher) ResumeTaskSession(context.Context, string, string) (*executor.TaskExecution, error) {
return nil, nil
}
func (m *mockSessionLauncher) GetMessageQueue() *messagequeue.Service { return nil }
func TestAutoStartTask_DefaultsToWorktreeExecutor(t *testing.T) {
launcher := newMockSessionLauncher()
log := testLogger(t)
h := &Handlers{
sessionLauncher: launcher,
logger: log.WithFields(),
}
task := &models.Task{
ID: "task-1",
WorkspaceID: "ws-1",
}
// Call with agent profile but no executor info
h.autoStartTask(task, "agent-profile-1", "", "")
select {
case <-launcher.called:
case <-time.After(2 * time.Second):
t.Fatal("LaunchSession was not called within timeout")
}
req := launcher.getRequest()
assert.Equal(t, models.ExecutorIDWorktree, req.ExecutorID, "should default to exec-worktree")
assert.Equal(t, "", req.ExecutorProfileID)
assert.Equal(t, "agent-profile-1", req.AgentProfileID)
}
func TestAutoStartTask_ExplicitExecutorProfilePreserved(t *testing.T) {
launcher := newMockSessionLauncher()
log := testLogger(t)
h := &Handlers{
sessionLauncher: launcher,
logger: log.WithFields(),
}
task := &models.Task{
ID: "task-1",
WorkspaceID: "ws-1",
}
// Call with explicit executor profile
h.autoStartTask(task, "agent-profile-1", "exec-profile-docker", "")
select {
case <-launcher.called:
case <-time.After(2 * time.Second):
t.Fatal("LaunchSession was not called within timeout")
}
req := launcher.getRequest()
assert.Equal(t, "exec-profile-docker", req.ExecutorProfileID, "explicit executor profile should be preserved")
assert.Equal(t, "", req.ExecutorID, "executorID should be empty when profile is set")
}
func TestResolveTaskRepositories_ExplicitRepos(t *testing.T) {
log := testLogger(t)
h := &Handlers{logger: log.WithFields()}
explicit := []mcpRepositoryInput{
{RepositoryID: "repo-1", BaseBranch: "main"},
{LocalPath: "/tmp/myrepo"},
}
result, err := h.resolveTaskRepositories(context.Background(), "", "", explicit)
require.NoError(t, err)
require.Len(t, result.Repos, 2)
assert.Equal(t, "repo-1", result.Repos[0].RepositoryID)
assert.Equal(t, "main", result.Repos[0].BaseBranch)
assert.Equal(t, "/tmp/myrepo", result.Repos[1].LocalPath)
assert.Empty(t, result.WorkspaceID, "workspace should not be set for explicit repos")
assert.Empty(t, result.WorkflowID, "workflow should not be set for explicit repos")
}
func TestResolveTaskRepositories_ExplicitGitHubURL(t *testing.T) {
log := testLogger(t)
h := &Handlers{logger: log.WithFields()}
explicit := []mcpRepositoryInput{
{GitHubURL: "https://github.com/acme/widgets", BaseBranch: "main"},
}
result, err := h.resolveTaskRepositories(context.Background(), "", "", explicit)
require.NoError(t, err)
require.Len(t, result.Repos, 1)
assert.Equal(t, "https://github.com/acme/widgets", result.Repos[0].GitHubURL)
assert.Equal(t, "main", result.Repos[0].BaseBranch)
}
func TestResolveTaskRepositories_NoInputs_ReturnsEmpty(t *testing.T) {
log := testLogger(t)
h := &Handlers{logger: log.WithFields()}
result, err := h.resolveTaskRepositories(context.Background(), "", "", nil)
require.NoError(t, err)
assert.Empty(t, result.Repos)
}
func TestResolveTaskRepositories_ExplicitRepos_SkipsParentLookup(t *testing.T) {
log := testLogger(t)
h := &Handlers{logger: log.WithFields()}
// Even with a parentID, explicit repos should be used without looking up the parent
// (taskSvc is nil — if it tried to call GetTask it would panic).
explicit := []mcpRepositoryInput{{RepositoryID: "repo-explicit"}}
result, err := h.resolveTaskRepositories(context.Background(), "some-parent", "some-source", explicit)
require.NoError(t, err)
require.Len(t, result.Repos, 1)
assert.Equal(t, "repo-explicit", result.Repos[0].RepositoryID)
}
// --- Integration tests using real task service ---
func TestResolveTaskRepositories_EphemeralParent_Rejected(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
// Seed workspace and ephemeral task
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"}))
task, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: "ws-1",
Title: "Quick Chat",
IsEphemeral: true,
})
require.NoError(t, err)
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
_, err = h.resolveTaskRepositories(ctx, task.ID, "", nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "ephemeral")
}
func TestResolveTaskRepositories_ExplicitRepos_InheritsSourceWorkspace(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
// Seed workspace and source task to inherit from.
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"}))
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"}))
_, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
Title: "Source task",
})
require.NoError(t, err)
tasks, err := svc.ListTasks(ctx, "wf-1")
require.NoError(t, err)
require.Len(t, tasks, 1)
sourceTaskID := tasks[0].ID
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
explicit := []mcpRepositoryInput{
{GitHubURL: "https://github.com/acme/widgets", BaseBranch: "main"},
}
result, err := h.resolveTaskRepositories(ctx, "", sourceTaskID, explicit)
require.NoError(t, err)
require.Len(t, result.Repos, 1)
assert.Equal(t, "https://github.com/acme/widgets", result.Repos[0].GitHubURL)
assert.Equal(t, "ws-1", result.WorkspaceID, "should inherit source task workspace even with explicit repos")
}
func TestResolveTaskRepositories_SourceTask_InheritsWorkspace(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
// Seed workspace, workflow, and source task
require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Test"}))
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Board"}))
_, err := svc.CreateTask(ctx, &service.CreateTaskRequest{
WorkspaceID: "ws-1",
WorkflowID: "wf-1",
Title: "Source task",
})
require.NoError(t, err)
tasks, err := svc.ListTasks(ctx, "wf-1")
require.NoError(t, err)
require.Len(t, tasks, 1)
sourceTaskID := tasks[0].ID
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
result, err := h.resolveTaskRepositories(ctx, "", sourceTaskID, nil)
require.NoError(t, err)
assert.Equal(t, "ws-1", result.WorkspaceID, "should inherit workspace from source task")
assert.Empty(t, result.WorkflowID, "should NOT inherit workflow from source task")
}
func TestHandleCreateTask_AutoResolvesWorkspaceAndWorkflow(t *testing.T) {
svc, _ := newTestTaskService(t)
ctx := context.Background()
// The DB is seeded with a default workspace and workflow by repository.Provide.
// Verify exactly 1 of each exists so auto-resolve works.
workspaces, wsErr := svc.ListWorkspaces(ctx)
require.NoError(t, wsErr)
require.Len(t, workspaces, 1, "should have exactly 1 default workspace")
workflows, wfErr := svc.ListWorkflows(ctx, workspaces[0].ID, false)
require.NoError(t, wfErr)
require.Len(t, workflows, 1, "should have exactly 1 default workflow")
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
// No workspace_id or workflow_id provided — should auto-resolve from defaults
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"title": "Auto-resolved task",
"start_agent": false,
})
resp, err := h.handleCreateTask(ctx, msg)
require.NoError(t, err)
require.NotNil(t, resp)
if resp.Type == ws.MessageTypeError {
t.Logf("error payload: %s", string(resp.Payload))
}
assert.Equal(t, ws.MessageTypeResponse, resp.Type, "should succeed with auto-resolved workspace and workflow")
}
func TestHandleCreateTask_AutoResolveFailsWithMultipleWorkflows(t *testing.T) {
svc, repo := newTestTaskService(t)
ctx := context.Background()
// The DB already has 1 default workspace + 1 default workflow.
// Add a second workflow to make auto-resolution ambiguous.
workspaces, err := svc.ListWorkspaces(ctx)
require.NoError(t, err)
require.Len(t, workspaces, 1)
require.NoError(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-extra", WorkspaceID: workspaces[0].ID, Name: "Extra Board"}))
log := testLogger(t)
h := &Handlers{taskSvc: svc, logger: log.WithFields()}
msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{
"title": "Task",
"start_agent": false,
})
resp, err := h.handleCreateTask(ctx, msg)
require.NoError(t, err)
assertWSError(t, resp, ws.ErrorCodeValidation)
}