Files
kandev/apps/backend/internal/integration/integration_test.go
T
Carlos Florêncio e1bce95bac feat: golang linter
2026-01-15 19:05:16 +00:00

1090 lines
29 KiB
Go

// 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/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)
}
})
// 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)
}