// Package integration provides end-to-end integration tests for the Kandev backend. // These tests start a real server and communicate via WebSocket. package integration import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "github.com/gin-gonic/gin" "github.com/gorilla/websocket" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/kandev/kandev/internal/agent/worktree" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" taskcontroller "github.com/kandev/kandev/internal/task/controller" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/repository" taskservice "github.com/kandev/kandev/internal/task/service" ws "github.com/kandev/kandev/pkg/websocket" ) // TestServer holds the test server and its dependencies type TestServer struct { Server *httptest.Server Gateway *gateways.Gateway TaskRepo repository.Repository TaskSvc *taskservice.Service EventBus bus.EventBus Logger *logger.Logger cancelFunc context.CancelFunc } // NewTestServer creates a new test server with all components initialized func NewTestServer(t *testing.T) *TestServer { 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) // Initialize task repository (SQLite for tests) tmpDir := t.TempDir() taskRepo, err := repository.NewSQLiteRepository(tmpDir + "/test.db") if err != nil { t.Fatalf("failed to create test repository: %v", err) } t.Cleanup(func() { if err := taskRepo.Close(); err != nil { t.Errorf("failed to close task repo: %v", err) } }) if _, err := worktree.NewSQLiteStore(taskRepo.DB()); err != nil { t.Fatalf("failed to init worktree store: %v", err) } // Initialize task service taskSvc := taskservice.NewService(taskRepo, eventBus, log, taskservice.RepositoryDiscoveryConfig{}) // Create WebSocket gateway gateway := gateways.NewGateway(log) // Start hub go gateway.Hub.Run(ctx) // Create router gin.SetMode(gin.TestMode) router := gin.New() gateway.SetupRoutes(router) // Register handlers (HTTP + WS) workspaceController := taskcontroller.NewWorkspaceController(taskSvc) boardController := taskcontroller.NewBoardController(taskSvc) taskController := taskcontroller.NewTaskController(taskSvc) executorController := taskcontroller.NewExecutorController(taskSvc) environmentController := taskcontroller.NewEnvironmentController(taskSvc) repositoryController := taskcontroller.NewRepositoryController(taskSvc) taskhandlers.RegisterWorkspaceRoutes(router, gateway.Dispatcher, workspaceController, log) taskhandlers.RegisterBoardRoutes(router, gateway.Dispatcher, boardController, log) taskhandlers.RegisterTaskRoutes(router, gateway.Dispatcher, taskController, log) taskhandlers.RegisterRepositoryRoutes(router, gateway.Dispatcher, repositoryController, log) taskhandlers.RegisterExecutorRoutes(router, gateway.Dispatcher, executorController, log) taskhandlers.RegisterEnvironmentRoutes(router, gateway.Dispatcher, environmentController, log) // Create test server server := httptest.NewServer(router) return &TestServer{ Server: server, Gateway: gateway, TaskRepo: taskRepo, TaskSvc: taskSvc, EventBus: eventBus, Logger: log, cancelFunc: cancel, } } // Close shuts down the test server func (ts *TestServer) Close() { ts.cancelFunc() ts.Server.Close() if err := ts.TaskRepo.Close(); err != nil { ts.Logger.Error("failed to close task repo: " + err.Error()) } ts.EventBus.Close() } // WSClient is a helper for WebSocket communication in tests type WSClient struct { conn *websocket.Conn t *testing.T messages chan *ws.Message done chan struct{} mu sync.Mutex } // NewWSClient creates a WebSocket connection to the test server func NewWSClient(t *testing.T, serverURL string) *WSClient { t.Helper() // Convert http:// to ws:// wsURL := "ws" + strings.TrimPrefix(serverURL, "http") + "/ws" dialer := websocket.Dialer{ HandshakeTimeout: 5 * time.Second, } conn, resp, err := dialer.Dial(wsURL, nil) require.NoError(t, err) require.Equal(t, http.StatusSwitchingProtocols, resp.StatusCode) client := &WSClient{ conn: conn, t: t, messages: make(chan *ws.Message, 100), done: make(chan struct{}), } // Start reading messages go client.readPump() return client } func createWorkspace(t *testing.T, client *WSClient) string { t.Helper() resp, err := client.SendRequest("workspace-1", ws.ActionWorkspaceCreate, map[string]interface{}{ "name": "Test Workspace", }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) return payload["id"].(string) } func createRepository(t *testing.T, client *WSClient, workspaceID string) string { t.Helper() resp, err := client.SendRequest("repo-1", ws.ActionRepositoryCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Test Repo", "source_type": "local", "local_path": "/tmp/repo", }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) return payload["id"].(string) } // readPump reads messages from the WebSocket connection func (c *WSClient) readPump() { defer close(c.done) for { _, data, err := c.conn.ReadMessage() if err != nil { return } // Handle newline-separated messages (server batches messages with newlines) parts := strings.Split(string(data), "\n") for _, part := range parts { part = strings.TrimSpace(part) if part == "" { continue } var msg ws.Message if err := json.Unmarshal([]byte(part), &msg); err != nil { continue } select { case c.messages <- &msg: default: // Buffer full, drop message } } } } // Close closes the WebSocket connection func (c *WSClient) Close() { if err := c.conn.Close(); err != nil { c.t.Logf("failed to close websocket: %v", err) } <-c.done } // SendRequest sends a request and waits for a response func (c *WSClient) SendRequest(id, action string, payload interface{}) (*ws.Message, error) { c.mu.Lock() defer c.mu.Unlock() msg, err := ws.NewRequest(id, action, payload) if err != nil { return nil, err } data, err := json.Marshal(msg) if err != nil { return nil, err } if err := c.conn.WriteMessage(websocket.TextMessage, data); err != nil { return nil, err } // Wait for response with matching ID timeout := time.After(5 * time.Second) for { select { case resp := <-c.messages: if resp.ID == id { return resp, nil } // Not our response, put it back (or buffer it) case <-timeout: return nil, context.DeadlineExceeded } } } // WaitForNotification waits for a notification with the given action func (c *WSClient) WaitForNotification(action string, timeout time.Duration) (*ws.Message, error) { deadline := time.After(timeout) for { select { case msg := <-c.messages: if msg.Type == ws.MessageTypeNotification && msg.Action == action { return msg, nil } case <-deadline: return nil, context.DeadlineExceeded } } } // ============================================ // HEALTH CHECK TESTS // ============================================ func TestHealthCheck(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() resp, err := client.SendRequest("health-1", ws.ActionHealthCheck, map[string]interface{}{}) require.NoError(t, err) assert.Equal(t, "health-1", resp.ID) assert.Equal(t, ws.MessageTypeResponse, resp.Type) assert.Equal(t, ws.ActionHealthCheck, resp.Action) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, "ok", payload["status"]) assert.Equal(t, "kandev", payload["service"]) } // ============================================ // BOARD TESTS // ============================================ func TestBoardCRUD(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) // Create board t.Run("CreateBoard", func(t *testing.T) { resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Test Board", "description": "A test board for integration testing", }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeResponse, resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.NotEmpty(t, payload["id"]) assert.Equal(t, "Test Board", payload["name"]) }) // List boards t.Run("ListBoards", func(t *testing.T) { resp, err := client.SendRequest("board-list-1", ws.ActionBoardList, map[string]interface{}{ "workspace_id": workspaceID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) boards, ok := payload["boards"].([]interface{}) require.True(t, ok) assert.Len(t, boards, 1) }) } // ============================================ // COLUMN TESTS // ============================================ func TestColumnCRUD(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) // First create a board boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Column Test Board", }) require.NoError(t, err) var boardPayload map[string]interface{} err = boardResp.ParsePayload(&boardPayload) require.NoError(t, err) boardID := boardPayload["id"].(string) // Create column t.Run("CreateColumn", func(t *testing.T) { resp, err := client.SendRequest("col-create-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeResponse, resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.NotEmpty(t, payload["id"]) assert.Equal(t, "To Do", payload["name"]) }) // Create second column t.Run("CreateSecondColumn", func(t *testing.T) { resp, err := client.SendRequest("col-create-2", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "In Progress", "position": 1, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, "In Progress", payload["name"]) }) // List columns t.Run("ListColumns", func(t *testing.T) { resp, err := client.SendRequest("col-list-1", ws.ActionColumnList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) columns, ok := payload["columns"].([]interface{}) require.True(t, ok) assert.Len(t, columns, 2) }) } // ============================================ // TASK TESTS // ============================================ func TestTaskCRUD(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) // Create board and column first boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Task Test Board", }) require.NoError(t, err) var boardPayload map[string]interface{} err = boardResp.ParsePayload(&boardPayload) require.NoError(t, err) boardID := boardPayload["id"].(string) colResp, err := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) require.NoError(t, err) var colPayload map[string]interface{} err = colResp.ParsePayload(&colPayload) require.NoError(t, err) columnID := colPayload["id"].(string) var taskID string // Create task t.Run("CreateTask", func(t *testing.T) { resp, err := client.SendRequest("task-create-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": columnID, "title": "Test Task", "description": "A test task for integration testing", "priority": 3, // HIGH priority (1=LOW, 2=MEDIUM, 3=HIGH) "repository_id": repositoryID, "base_branch": "main", }) require.NoError(t, err) // Debug: print error if there is one if resp.Type == ws.MessageTypeError { var errPayload ws.ErrorPayload if err := resp.ParsePayload(&errPayload); err != nil { t.Fatalf("failed to parse error payload: %v", err) } t.Logf("Error response: %+v", errPayload) } require.Equal(t, ws.MessageTypeResponse, resp.Type, "Expected response but got %s", resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) taskID = payload["id"].(string) assert.NotEmpty(t, taskID) assert.Equal(t, "Test Task", payload["title"]) assert.Equal(t, "CREATED", payload["state"]) }) // Get task t.Run("GetTask", func(t *testing.T) { resp, err := client.SendRequest("task-get-1", ws.ActionTaskGet, map[string]interface{}{ "id": taskID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, taskID, payload["id"]) assert.Equal(t, "Test Task", payload["title"]) }) // Update task t.Run("UpdateTask", func(t *testing.T) { resp, err := client.SendRequest("task-update-1", ws.ActionTaskUpdate, map[string]interface{}{ "id": taskID, "title": "Updated Task Title", }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, "Updated Task Title", payload["title"]) }) // List tasks t.Run("ListTasks", func(t *testing.T) { resp, err := client.SendRequest("task-list-1", ws.ActionTaskList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) tasks, ok := payload["tasks"].([]interface{}) require.True(t, ok) assert.Len(t, tasks, 1) }) // Delete task t.Run("DeleteTask", func(t *testing.T) { resp, err := client.SendRequest("task-delete-1", ws.ActionTaskDelete, map[string]interface{}{ "id": taskID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, true, payload["success"]) // Verify task is deleted listResp, err := client.SendRequest("task-list-2", ws.ActionTaskList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var listPayload map[string]interface{} err = listResp.ParsePayload(&listPayload) require.NoError(t, err) tasks, ok := listPayload["tasks"].([]interface{}) require.True(t, ok) assert.Len(t, tasks, 0) }) } // ============================================ // TASK STATE TESTS // ============================================ func TestTaskStateTransitions(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) // Create board, column, and task boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "State Test Board", }) var boardPayload map[string]interface{} if err := boardResp.ParsePayload(&boardPayload); err != nil { t.Fatalf("failed to parse board payload: %v", err) } boardID := boardPayload["id"].(string) colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) var colPayload map[string]interface{} if err := colResp.ParsePayload(&colPayload); err != nil { t.Fatalf("failed to parse column payload: %v", err) } columnID := colPayload["id"].(string) taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": columnID, "title": "State Test Task", "description": "Test state transitions", "repository_id": repositoryID, "base_branch": "main", }) var taskPayload map[string]interface{} if err := taskResp.ParsePayload(&taskPayload); err != nil { t.Fatalf("failed to parse task payload: %v", err) } taskID := taskPayload["id"].(string) // Test state transitions stateTests := []struct { name string newState string }{ {"ToInProgress", "IN_PROGRESS"}, {"ToCompleted", "COMPLETED"}, {"BackToTodo", "TODO"}, {"ToBlocked", "BLOCKED"}, } for i, tc := range stateTests { t.Run(tc.name, func(t *testing.T) { resp, err := client.SendRequest( "state-"+string(rune('0'+i)), ws.ActionTaskState, map[string]interface{}{ "id": taskID, "state": tc.newState, }, ) require.NoError(t, err) assert.Equal(t, ws.MessageTypeResponse, resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, tc.newState, payload["state"]) }) } } // ============================================ // TASK MOVE TESTS // ============================================ func TestTaskMove(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) // Create board with two columns boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Move Test Board", }) var boardPayload map[string]interface{} if err := boardResp.ParsePayload(&boardPayload); err != nil { t.Fatalf("failed to parse board payload: %v", err) } boardID := boardPayload["id"].(string) col1Resp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) var col1Payload map[string]interface{} if err := col1Resp.ParsePayload(&col1Payload); err != nil { t.Fatalf("failed to parse column payload: %v", err) } col1ID := col1Payload["id"].(string) col2Resp, _ := client.SendRequest("col-2", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "Done", "position": 1, }) var col2Payload map[string]interface{} if err := col2Resp.ParsePayload(&col2Payload); err != nil { t.Fatalf("failed to parse column payload: %v", err) } col2ID := col2Payload["id"].(string) // Create task in first column taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": col1ID, "title": "Movable Task", "repository_id": repositoryID, "base_branch": "main", }) var taskPayload map[string]interface{} if err := taskResp.ParsePayload(&taskPayload); err != nil { t.Fatalf("failed to parse task payload: %v", err) } taskID := taskPayload["id"].(string) // Move task to second column t.Run("MoveToColumn2", func(t *testing.T) { resp, err := client.SendRequest("move-1", ws.ActionTaskMove, map[string]interface{}{ "id": taskID, "board_id": boardID, "column_id": col2ID, "position": 0, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, col2ID, payload["column_id"]) }) // Verify task is now in column 2 t.Run("VerifyMove", func(t *testing.T) { resp, err := client.SendRequest("get-1", ws.ActionTaskGet, map[string]interface{}{ "id": taskID, }) require.NoError(t, err) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, col2ID, payload["column_id"]) }) } // ============================================ // MULTIPLE CLIENTS TESTS // ============================================ func TestMultipleClients(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client1 := NewWSClient(t, ts.Server.URL) defer client1.Close() client2 := NewWSClient(t, ts.Server.URL) defer client2.Close() workspaceID := createWorkspace(t, client1) repositoryID := createRepository(t, client1, workspaceID) // Client 1 creates a board boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Shared Board", }) require.NoError(t, err) var boardPayload map[string]interface{} err = boardResp.ParsePayload(&boardPayload) require.NoError(t, err) boardID := boardPayload["id"].(string) // Client 2 can see the board (filter by workspace to avoid default board) listResp, err := client2.SendRequest("c2-list-1", ws.ActionBoardList, map[string]interface{}{ "workspace_id": workspaceID, }) require.NoError(t, err) var listPayload map[string]interface{} err = listResp.ParsePayload(&listPayload) require.NoError(t, err) boards, ok := listPayload["boards"].([]interface{}) require.True(t, ok) assert.Len(t, boards, 1) // Client 2 creates a column colResp, err := client2.SendRequest("c2-col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "Column by Client 2", "position": 0, }) require.NoError(t, err) var colPayload map[string]interface{} err = colResp.ParsePayload(&colPayload) require.NoError(t, err) columnID := colPayload["id"].(string) // Client 1 can see the column colListResp, err := client1.SendRequest("c1-col-list-1", ws.ActionColumnList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var colListPayload map[string]interface{} err = colListResp.ParsePayload(&colListPayload) require.NoError(t, err) columns, ok := colListPayload["columns"].([]interface{}) require.True(t, ok) assert.Len(t, columns, 1) // Client 1 creates a task in the column taskResp, err := client1.SendRequest("c1-task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": columnID, "title": "Task by Client 1", "repository_id": repositoryID, "base_branch": "main", }) require.NoError(t, err) var taskPayload map[string]interface{} err = taskResp.ParsePayload(&taskPayload) require.NoError(t, err) // Client 2 can see the task taskListResp, err := client2.SendRequest("c2-task-list-1", ws.ActionTaskList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var taskListPayload map[string]interface{} err = taskListResp.ParsePayload(&taskListPayload) require.NoError(t, err) tasks, ok := taskListPayload["tasks"].([]interface{}) require.True(t, ok) assert.Len(t, tasks, 1) } // ============================================ // ERROR HANDLING TESTS // ============================================ func TestErrorHandling(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() t.Run("UnknownAction", func(t *testing.T) { resp, err := client.SendRequest("err-1", "unknown.action", map[string]interface{}{}) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) var payload ws.ErrorPayload err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, ws.ErrorCodeUnknownAction, payload.Code) }) t.Run("GetNonExistentBoard", func(t *testing.T) { resp, err := client.SendRequest("err-2", ws.ActionBoardGet, map[string]interface{}{ "id": "non-existent-board-id", }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) }) t.Run("GetNonExistentTask", func(t *testing.T) { resp, err := client.SendRequest("err-3", ws.ActionTaskGet, map[string]interface{}{ "id": "non-existent-task-id", }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) }) t.Run("CreateTaskWithoutBoard", func(t *testing.T) { resp, err := client.SendRequest("err-4", ws.ActionTaskCreate, map[string]interface{}{ "title": "Orphan Task", }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) }) t.Run("CreateColumnWithoutBoard", func(t *testing.T) { resp, err := client.SendRequest("err-5", ws.ActionColumnCreate, map[string]interface{}{ "name": "Orphan Column", "position": 0, }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeError, resp.Type) }) } // ============================================ // TASK SUBSCRIPTION TESTS // ============================================ func TestTaskSubscription(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) // Create a task first boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Sub Test Board", }) var boardPayload map[string]interface{} if err := boardResp.ParsePayload(&boardPayload); err != nil { t.Fatalf("failed to parse board payload: %v", err) } boardID := boardPayload["id"].(string) colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) var colPayload map[string]interface{} if err := colResp.ParsePayload(&colPayload); err != nil { t.Fatalf("failed to parse column payload: %v", err) } columnID := colPayload["id"].(string) taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": columnID, "title": "Subscribable Task", "repository_id": repositoryID, "base_branch": "main", }) var taskPayload map[string]interface{} if err := taskResp.ParsePayload(&taskPayload); err != nil { t.Fatalf("failed to parse task payload: %v", err) } taskID := taskPayload["id"].(string) // Subscribe to the task t.Run("Subscribe", func(t *testing.T) { resp, err := client.SendRequest("sub-1", ws.ActionTaskSubscribe, map[string]interface{}{ "task_id": taskID, }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeResponse, resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, true, payload["success"]) assert.Equal(t, taskID, payload["task_id"]) }) // Unsubscribe from the task t.Run("Unsubscribe", func(t *testing.T) { resp, err := client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{ "task_id": taskID, }) require.NoError(t, err) assert.Equal(t, ws.MessageTypeResponse, resp.Type) var payload map[string]interface{} err = resp.ParsePayload(&payload) require.NoError(t, err) assert.Equal(t, true, payload["success"]) }) } // ============================================ // CONCURRENT REQUEST TESTS // ============================================ func TestConcurrentRequests(t *testing.T) { ts := NewTestServer(t) defer ts.Close() client := NewWSClient(t, ts.Server.URL) defer client.Close() workspaceID := createWorkspace(t, client) repositoryID := createRepository(t, client, workspaceID) // Create board and column boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{ "workspace_id": workspaceID, "name": "Concurrent Test Board", }) var boardPayload map[string]interface{} if err := boardResp.ParsePayload(&boardPayload); err != nil { t.Fatalf("failed to parse board payload: %v", err) } boardID := boardPayload["id"].(string) colResp, _ := client.SendRequest("col-1", ws.ActionColumnCreate, map[string]interface{}{ "board_id": boardID, "name": "To Do", "position": 0, }) var colPayload map[string]interface{} if err := colResp.ParsePayload(&colPayload); err != nil { t.Fatalf("failed to parse column payload: %v", err) } columnID := colPayload["id"].(string) // Create multiple tasks concurrently numTasks := 10 var wg sync.WaitGroup results := make(chan error, numTasks) for i := 0; i < numTasks; i++ { wg.Add(1) go func(idx int) { defer wg.Done() // Create separate client for concurrency c := NewWSClient(t, ts.Server.URL) defer c.Close() _, err := c.SendRequest( "task-"+string(rune('0'+idx)), ws.ActionTaskCreate, map[string]interface{}{ "workspace_id": workspaceID, "board_id": boardID, "column_id": columnID, "title": "Concurrent Task " + string(rune('0'+idx)), "repository_id": repositoryID, "base_branch": "main", }, ) results <- err }(i) } wg.Wait() close(results) // Verify all tasks were created for err := range results { assert.NoError(t, err) } // Verify task count listResp, err := client.SendRequest("list-1", ws.ActionTaskList, map[string]interface{}{ "board_id": boardID, }) require.NoError(t, err) var listPayload map[string]interface{} err = listResp.ParsePayload(&listPayload) require.NoError(t, err) tasks, ok := listPayload["tasks"].([]interface{}) require.True(t, ok) assert.Len(t, tasks, numTasks) }