Files
kandev/apps/backend/internal/integration/test_server_test.go
T
2026-09-15 09:36:47 +01:00

592 lines
21 KiB
Go

// Package integration provides end-to-end integration tests for the Kandev backend.
package integration
import (
"context"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/jmoiron/sqlx"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"github.com/kandev/kandev/internal/agentctl/types/streams"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/db"
"github.com/kandev/kandev/internal/events/bus"
gateways "github.com/kandev/kandev/internal/gateway/websocket"
"github.com/kandev/kandev/internal/orchestrator"
orchestratorhandlers "github.com/kandev/kandev/internal/orchestrator/handlers"
taskhandlers "github.com/kandev/kandev/internal/task/handlers"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository"
sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/internal/workflow"
workflowcontroller "github.com/kandev/kandev/internal/workflow/controller"
workflowhandlers "github.com/kandev/kandev/internal/workflow/handlers"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
workflowservice "github.com/kandev/kandev/internal/workflow/service"
"github.com/kandev/kandev/internal/worktree"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
// OrchestratorTestServer extends TestServer with orchestrator components
type OrchestratorTestServer struct {
Server *httptest.Server
Gateway *gateways.Gateway
TaskRepo *sqliterepo.Repository
TaskSvc *taskservice.Service
WorkflowSvc *workflowservice.Service
EventBus bus.EventBus
OrchestratorSvc *orchestrator.Service
AgentManager *SimulatedAgentManagerClient
Logger *logger.Logger
ctx context.Context
cancelFunc context.CancelFunc
}
// taskRepositoryAdapter adapts the task repository for the orchestrator
type taskRepositoryAdapter struct {
repo *sqliterepo.Repository
svc *taskservice.Service
}
type orchestratorWorkflowStepGetter struct {
svc *workflowservice.Service
}
type integrationWorkflowProvider struct {
svc *taskservice.Service
}
func (a *integrationWorkflowProvider) ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*models.Workflow, error) {
return a.svc.ListWorkflows(ctx, workspaceID, includeHidden)
}
func (a *integrationWorkflowProvider) GetWorkflow(ctx context.Context, id string) (*models.Workflow, error) {
return a.svc.GetWorkflow(ctx, id)
}
func (a *integrationWorkflowProvider) CreateWorkflow(ctx context.Context, workspaceID, name, description string) (*models.Workflow, error) {
return a.svc.CreateWorkflow(ctx, &taskservice.CreateWorkflowRequest{
WorkspaceID: workspaceID,
Name: name,
Description: description,
})
}
func (a *integrationWorkflowProvider) UpdateWorkflow(ctx context.Context, workflow *models.Workflow) error {
prompt := workflow.Prompt
_, err := a.svc.UpdateWorkflow(ctx, workflow.ID, &taskservice.UpdateWorkflowRequest{
Name: &workflow.Name,
Description: &workflow.Description,
Prompt: &prompt,
AgentProfileID: &workflow.AgentProfileID,
})
return err
}
func (a orchestratorWorkflowStepGetter) GetStep(ctx context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
return a.svc.GetStep(ctx, stepID)
}
func (a orchestratorWorkflowStepGetter) GetNextStepByPosition(ctx context.Context, workflowID string, position int) (*wfmodels.WorkflowStep, error) {
return a.svc.GetNextStepByPosition(ctx, workflowID, position)
}
func (a orchestratorWorkflowStepGetter) GetPreviousStepByPosition(ctx context.Context, workflowID string, position int) (*wfmodels.WorkflowStep, error) {
return a.svc.GetPreviousStepByPosition(ctx, workflowID, position)
}
func (a orchestratorWorkflowStepGetter) GetWorkflowMeta(ctx context.Context, workflowID string) (orchestrator.WorkflowMeta, error) {
meta, err := a.svc.GetWorkflowMeta(ctx, workflowID)
if err != nil {
return orchestrator.WorkflowMeta{}, err
}
return orchestrator.WorkflowMeta{AgentProfileID: meta.AgentProfileID, Prompt: meta.Prompt}, nil
}
func (a *taskRepositoryAdapter) GetTask(ctx context.Context, taskID string) (*v1.Task, error) {
task, err := a.repo.GetTask(ctx, taskID)
if err != nil {
return nil, err
}
return task.ToAPI(), nil
}
func (a *taskRepositoryAdapter) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error {
_, err := a.svc.UpdateTaskState(ctx, taskID, state)
return err
}
func (a *taskRepositoryAdapter) UpdateTaskStateIfCurrentIn(
ctx context.Context, taskID string, state v1.TaskState, allowed []v1.TaskState,
) (bool, error) {
return a.svc.UpdateTaskStateIfCurrentIn(ctx, taskID, state, allowed)
}
func (a *taskRepositoryAdapter) UpdateTaskStateIfNotArchived(
ctx context.Context, taskID string, state v1.TaskState,
) (bool, error) {
return a.svc.UpdateTaskStateIfNotArchived(ctx, taskID, state)
}
func (a *taskRepositoryAdapter) UpdateTaskStateIfSessionState(
ctx context.Context,
taskID, sessionID string,
expectedSessionState models.TaskSessionState,
state v1.TaskState,
) (bool, error) {
return a.svc.UpdateTaskStateIfSessionState(ctx, taskID, sessionID, expectedSessionState, state)
}
// testMessageCreatorAdapter adapts the task service to the orchestrator.MessageCreator interface for tests
type testMessageCreatorAdapter struct {
svc *taskservice.Service
}
func (a *testMessageCreatorAdapter) CreateAgentMessage(ctx context.Context, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
})
return err
}
func (a *testMessageCreatorAdapter) CreateUserMessage(ctx context.Context, taskID, content, agentSessionID, turnID string, metadata map[string]interface{}) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "user",
Metadata: metadata,
})
return err
}
func (a *testMessageCreatorAdapter) CreateUserMessageIdempotent(
ctx context.Context,
messageID, taskID, content, agentSessionID, turnID string,
metadata map[string]interface{},
) error {
_, err := a.svc.CreateMessageIdempotent(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "user",
Metadata: metadata,
})
return err
}
func (a *testMessageCreatorAdapter) CreateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, title, status, agentSessionID, turnID string, normalized *streams.NormalizedPayload) error {
metadata := map[string]interface{}{
"tool_call_id": toolCallID,
"title": title,
"status": status,
}
if parentToolCallID != "" {
metadata["parent_tool_call_id"] = parentToolCallID
}
if normalized != nil {
metadata["normalized"] = normalized
}
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: title,
AuthorType: "agent",
Type: "tool_call",
Metadata: metadata,
})
return err
}
func (a *testMessageCreatorAdapter) UpdateToolCallMessage(ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, normalized *streams.NormalizedPayload) error {
return a.svc.UpdateToolCallMessageWithCreate(ctx, agentSessionID, toolCallID, parentToolCallID, status, result, title, normalized, taskID, turnID, msgType)
}
func (a *testMessageCreatorAdapter) CreateSessionMessage(ctx context.Context, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
_, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
Type: messageType,
Metadata: metadata,
RequestsInput: requestsInput,
})
return err
}
func (a *testMessageCreatorAdapter) CreateSessionMessageIdempotent(ctx context.Context, messageID, taskID, content, agentSessionID, messageType, turnID string, metadata map[string]interface{}, requestsInput bool) error {
_, err := a.svc.CreateMessageIdempotent(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
Type: messageType,
Metadata: metadata,
RequestsInput: requestsInput,
})
return err
}
func (a *testMessageCreatorAdapter) CreatePermissionRequestMessage(ctx context.Context, taskID, sessionID, requestID, pendingID, toolCallID, title, turnID string, options []map[string]interface{}, actionType string, actionDetails map[string]interface{}) (string, error) {
metadata := map[string]interface{}{
"request_id": requestID,
"pending_id": pendingID,
"tool_call_id": toolCallID,
"options": options,
"action_type": actionType,
"action_details": actionDetails,
}
msg, err := a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{
TaskSessionID: sessionID,
TaskID: taskID,
TurnID: turnID,
Content: title,
AuthorType: "agent",
Type: "permission_request",
Metadata: metadata,
})
if err != nil {
return "", err
}
return msg.ID, nil
}
func (a *testMessageCreatorAdapter) UpdatePermissionMessage(ctx context.Context, taskID, sessionID, requestID, pendingID string, status models.PermissionStatus) error {
return a.svc.UpdatePermissionMessage(ctx, taskID, sessionID, requestID, pendingID, status)
}
func (a *testMessageCreatorAdapter) ClaimPermissionResolution(ctx context.Context, request models.PermissionResolutionClaimRequest) (*models.PermissionResolutionClaimResult, error) {
return a.svc.ClaimPermissionResolution(ctx, request)
}
func (a *testMessageCreatorAdapter) FinalizePermissionResolution(ctx context.Context, request models.PermissionResolutionFinalizeRequest) (*models.PermissionResolutionFinalizeResult, error) {
return a.svc.FinalizePermissionResolution(ctx, request)
}
func (a *testMessageCreatorAdapter) GetPermissionResolutionAudit(ctx context.Context, taskID, sessionID, requestID, pendingID string) (*models.PermissionResolutionAudit, error) {
return a.svc.GetPermissionResolutionAudit(ctx, taskID, sessionID, requestID, pendingID)
}
func (a *testMessageCreatorAdapter) CreateAgentMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: content,
AuthorType: "agent",
Type: "content",
})
return err
}
func (a *testMessageCreatorAdapter) AppendAgentMessage(ctx context.Context, messageID, additionalContent string) error {
return a.svc.AppendMessageContent(ctx, messageID, additionalContent)
}
func (a *testMessageCreatorAdapter) CreateThinkingMessageStreaming(ctx context.Context, messageID, taskID, content, agentSessionID, turnID string) error {
_, err := a.svc.CreateMessageWithID(ctx, messageID, &taskservice.CreateMessageRequest{
TaskSessionID: agentSessionID,
TaskID: taskID,
TurnID: turnID,
Content: "",
AuthorType: "agent",
Type: "thinking",
Metadata: map[string]interface{}{
"thinking": content,
},
})
return err
}
func (a *testMessageCreatorAdapter) AppendThinkingMessage(ctx context.Context, messageID, additionalContent string) error {
return a.svc.AppendThinkingContent(ctx, messageID, additionalContent)
}
func (a *testMessageCreatorAdapter) InvalidateModelCache(string) {}
// testTurnServiceAdapter adapts the task service to the orchestrator.TurnService interface for tests
type testTurnServiceAdapter struct {
svc *taskservice.Service
}
func (a *testTurnServiceAdapter) StartTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.StartTurn(ctx, sessionID)
}
func (a *testTurnServiceAdapter) ReserveTurn(
ctx context.Context,
sessionID string,
recovery *models.PromptDispatchRecovery,
) (*models.Turn, error) {
return a.svc.ReserveTurn(ctx, sessionID, recovery)
}
func (a *testTurnServiceAdapter) PublishReservedTurn(ctx context.Context, turn *models.Turn) error {
return a.svc.PublishReservedTurn(ctx, turn)
}
func (a *testTurnServiceAdapter) MarkReservedTurnDispatchAttempted(ctx context.Context, turn *models.Turn) error {
return a.svc.MarkReservedTurnDispatchAttempted(ctx, turn)
}
func (a *testTurnServiceAdapter) RollbackReservedTurn(
ctx context.Context,
sessionID, turnID string,
) (bool, error) {
return a.svc.RollbackReservedTurn(ctx, sessionID, turnID)
}
func (a *testTurnServiceAdapter) ReconcileUnpublishedPromptTurns(ctx context.Context) (int, error) {
return a.svc.ReconcileUnpublishedPromptTurns(ctx)
}
func (a *testTurnServiceAdapter) CompleteTurn(ctx context.Context, turnID string) error {
return a.svc.CompleteTurn(ctx, turnID)
}
func (a *testTurnServiceAdapter) GetTurn(ctx context.Context, turnID string) (*models.Turn, error) {
return a.svc.GetTurn(ctx, turnID)
}
func (a *testTurnServiceAdapter) GetActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) {
return a.svc.GetActiveTurn(ctx, sessionID)
}
func (a *testTurnServiceAdapter) UpdateTurn(ctx context.Context, turn *models.Turn) error {
return a.svc.UpdateTurn(ctx, turn)
}
func (a *testTurnServiceAdapter) PatchTurnMetadata(
ctx context.Context,
sessionID, turnID string,
updates map[string]interface{},
) error {
return a.svc.PatchTurnMetadata(ctx, sessionID, turnID, updates)
}
func (a *testTurnServiceAdapter) AbandonOpenTurns(ctx context.Context, sessionID string) error {
return a.svc.AbandonOpenTurns(ctx, sessionID)
}
// NewOrchestratorTestServer creates a test server with full orchestrator support
func NewOrchestratorTestServer(t *testing.T) *OrchestratorTestServer {
t.Helper()
// Initialize logger (quiet for tests)
log, err := logger.NewLogger(logger.LoggingConfig{
Level: "error",
Format: "console",
})
require.NoError(t, err)
// Create context
ctx, cancel := context.WithCancel(context.Background())
// Initialize event bus
eventBus := bus.NewMemoryEventBus(log)
tmpDir := t.TempDir()
dbConn, err := db.OpenSQLite(filepath.Join(tmpDir, "test.db"))
require.NoError(t, err)
sqlxDB := sqlx.NewDb(dbConn, "sqlite3")
taskRepoImpl, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil)
require.NoError(t, err)
taskRepo := taskRepoImpl
t.Cleanup(func() {
if err := sqlxDB.Close(); err != nil {
t.Errorf("failed to close sqlite db: %v", err)
}
if cleanup != nil {
if err := cleanup(); err != nil {
t.Errorf("failed to close task repo: %v", err)
}
}
})
if _, err := worktree.NewSQLiteStore(sqlxDB, sqlxDB); err != nil {
t.Fatalf("failed to init worktree store: %v", err)
}
// Initialize workflow service
_, workflowSvc, _, err := workflow.Provide(sqlxDB, sqlxDB, log)
require.NoError(t, err)
// Initialize task service and wire workflow step creator
taskSvc := taskservice.NewService(taskservice.Repos{
Workspaces: taskRepo, Tasks: taskRepo, TaskRepos: taskRepo,
Workflows: taskRepo, Messages: taskRepo, Turns: taskRepo,
Sessions: taskRepo, GitSnapshots: taskRepo, RepoEntities: taskRepo,
Executors: taskRepo, Environments: taskRepo, TaskEnvironments: taskRepo,
Reviews: taskRepo,
}, eventBus, log, taskservice.RepositoryDiscoveryConfig{})
taskSvc.SetWorkflowStepCreator(workflowSvc)
taskSvc.SetWorkflowStepGetter(workflowSvc)
taskSvc.SetWorkspaceBootstrapper(taskRepo)
workflowSvc.SetWorkflowProvider(&integrationWorkflowProvider{svc: taskSvc})
// Create simulated agent manager
agentManager := NewSimulatedAgentManager(eventBus, log)
// Create task repository adapter
taskRepoAdapter := &taskRepositoryAdapter{repo: taskRepo, svc: taskSvc}
// Create orchestrator service
cfg := orchestrator.DefaultServiceConfig()
cfg.Scheduler.ProcessInterval = 50 * time.Millisecond // Faster for tests
orchestratorSvc := orchestrator.NewService(cfg, eventBus, agentManager, taskRepoAdapter, taskRepo, nil, nil, nil, log)
// Wire message creator for message persistence (similar to cmd/kandev/orchestrator.go)
msgCreator := &testMessageCreatorAdapter{svc: taskSvc}
orchestratorSvc.SetMessageCreator(msgCreator)
orchestratorSvc.SetTurnService(&testTurnServiceAdapter{svc: taskSvc})
orchestratorSvc.SetWorkflowStepGetter(orchestratorWorkflowStepGetter{svc: workflowSvc})
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register orchestrator handlers (Pattern A)
orchestratorHandlers := orchestratorhandlers.NewHandlers(orchestratorSvc, log)
orchestratorHandlers.RegisterHandlers(gateway.Dispatcher)
// Start hub
go gateway.Hub.Run(ctx)
// Register task notifications to broadcast events to WebSocket clients
gateways.RegisterTaskNotifications(ctx, eventBus, gateway.Hub, log)
// Start orchestrator
require.NoError(t, orchestratorSvc.Start(ctx))
// Create router
gin.SetMode(gin.TestMode)
router := gin.New()
gateway.SetupRoutes(router)
// Register handlers (HTTP + WS)
workflowCtrl := workflowcontroller.NewController(workflowSvc)
taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterWorkflowRoutes(router, gateway.Dispatcher, taskSvc, workflowSvc, log)
planService := taskservice.NewPlanService(taskRepo, eventBus, log)
taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskSvc, nil, taskRepo, planService, log)
taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, taskSvc, log)
taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, taskSvc, log)
workflowhandlers.RegisterRoutes(router, gateway.Dispatcher, workflowCtrl, eventBus, log)
// Create test server
server := httptest.NewServer(router)
return &OrchestratorTestServer{
Server: server,
Gateway: gateway,
TaskRepo: taskRepo,
TaskSvc: taskSvc,
WorkflowSvc: workflowSvc,
EventBus: eventBus,
OrchestratorSvc: orchestratorSvc,
AgentManager: agentManager,
Logger: log,
ctx: ctx,
cancelFunc: cancel,
}
}
// Close shuts down the test server
func (ts *OrchestratorTestServer) Close() {
if err := ts.OrchestratorSvc.Stop(); err != nil {
ts.Logger.Warn("failed to stop orchestrator", zap.Error(err))
}
ts.AgentManager.Close()
ts.cancelFunc()
ts.Server.Close()
if err := ts.TaskRepo.Close(); err != nil {
ts.Logger.Warn("failed to close task repo", zap.Error(err))
}
ts.EventBus.Close()
}
// CreateTestTask creates a task for testing.
func (ts *OrchestratorTestServer) CreateTestTask(t *testing.T, agentProfileID string, priority int) string {
t.Helper()
workspace, err := ts.TaskSvc.CreateWorkspace(context.Background(), &taskservice.CreateWorkspaceRequest{
Name: "Test Workspace",
})
require.NoError(t, err)
// Create workflow first (workflow steps are created automatically via default template)
defaultTemplateID := "simple"
wf, err := ts.TaskSvc.CreateWorkflow(context.Background(), &taskservice.CreateWorkflowRequest{
WorkspaceID: workspace.ID,
Name: "Test Workflow",
Description: "Test workflow for orchestrator",
WorkflowTemplateID: &defaultTemplateID,
})
require.NoError(t, err)
// Get first workflow step from workflow
steps, err := ts.WorkflowSvc.ListStepsByWorkflow(context.Background(), wf.ID)
require.NoError(t, err)
require.NotEmpty(t, steps, "workflow should have workflow steps")
workflowStepID := steps[0].ID
// Create task with agent profile ID
repository, err := ts.TaskSvc.CreateRepository(context.Background(), &taskservice.CreateRepositoryRequest{
WorkspaceID: workspace.ID,
Name: "Test Repo",
LocalPath: createTempRepoDir(t),
})
require.NoError(t, err)
taskResult, err := ts.TaskSvc.CreateTask(context.Background(), &taskservice.CreateTaskRequest{
WorkspaceID: workspace.ID,
WorkflowID: wf.ID,
WorkflowStepID: workflowStepID,
Title: "Test Task",
Description: "This is a test task for the orchestrator",
Priority: intPriorityForTest(priority),
Repositories: []taskservice.TaskRepositoryInput{
{
RepositoryID: repository.ID,
BaseBranch: "main",
},
},
})
task := taskResult.Task
require.NoError(t, err)
return task.ID
}
// intPriorityForTest maps a legacy integer priority to the TEXT priority
// label form used after the priority column migration.
func intPriorityForTest(p int) string {
switch {
case p >= 8:
return "critical"
case p >= 4:
return "high"
case p >= 2:
return "medium"
case p >= 1:
return "low"
default:
return "medium"
}
}