Files
kandev/apps/backend/internal/integration/integration_test.go
T
jcfs 6a46eb5a54 feat: implement persistent agent execution logs storage and retrieval
- Add task_agent_execution_logs table schema in SQLite with indexes
- Create SQLiteMessageStore implementing MessageStore interface for persistent ACP message storage
- Add task.logs WebSocket action for retrieving historical logs
- Implement historical log replay on task.subscribe
- Add HistoricalLogsProvider callback to WebSocket Hub

Tests:
- Add comprehensive unit tests for SQLiteMessageStore (29 tests)
- Add integration tests for task.logs and historical replay (3 test suites)
- Enhance e2e-test.sh with historical logs test scenarios (Steps 14-16)

Files:
- apps/backend/internal/orchestrator/acp/sqlite_store.go (new)
- apps/backend/internal/orchestrator/acp/sqlite_store_test.go (new)
- apps/backend/internal/task/repository/sqlite.go (schema)
- apps/backend/internal/gateway/websocket/hub.go (provider)
- apps/backend/internal/gateway/websocket/client.go (replay)
- apps/backend/internal/orchestrator/wshandlers/handlers.go (task.logs)
- apps/backend/cmd/kandev/main.go (wiring)
- scripts/e2e-test.sh (test scenarios)
2026-01-11 01:24:54 +00:00

1502 lines
41 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"
"github.com/kandev/kandev/internal/orchestrator/acp"
"github.com/kandev/kandev/internal/task/repository"
taskservice "github.com/kandev/kandev/internal/task/service"
taskwshandlers "github.com/kandev/kandev/internal/task/wshandlers"
"github.com/kandev/kandev/pkg/acp/protocol"
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 (in-memory for tests)
taskRepo := repository.NewMemoryRepository()
// Initialize task service
taskSvc := taskservice.NewService(taskRepo, eventBus, log)
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register handlers
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
// Start hub
go gateway.Hub.Run(ctx)
// Create router
gin.SetMode(gin.TestMode)
router := gin.New()
gateway.SetupRoutes(router)
// 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()
ts.TaskRepo.Close()
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
}
// 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() {
c.conn.Close()
<-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()
// Create board
t.Run("CreateBoard", func(t *testing.T) {
resp, err := client.SendRequest("board-create-1", ws.ActionBoardCreate, map[string]interface{}{
"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{}{})
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()
// First create a board
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"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()
// Create board and column first
boardResp, err := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"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{}{
"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)
})
require.NoError(t, err)
// Debug: print error if there is one
if resp.Type == ws.MessageTypeError {
var errPayload ws.ErrorPayload
resp.ParsePayload(&errPayload)
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, "TODO", 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()
// Create board, column, and task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "State Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "State Test Task",
"description": "Test state transitions",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
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()
// Create board with two columns
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Move Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
col1Resp.ParsePayload(&col1Payload)
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{}
col2Resp.ParsePayload(&col2Payload)
col2ID := col2Payload["id"].(string)
// Create task in first column
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": col1ID,
"title": "Movable Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
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,
"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()
// Client 1 creates a board
boardResp, err := client1.SendRequest("c1-board-1", ws.ActionBoardCreate, map[string]interface{}{
"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
listResp, err := client2.SendRequest("c2-list-1", ws.ActionBoardList, map[string]interface{}{})
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{}{
"board_id": boardID,
"column_id": columnID,
"title": "Task by Client 1",
})
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()
// Create a task first
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Sub Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
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"])
})
}
// ============================================
// HISTORICAL LOGS TESTS
// ============================================
// TestServerWithACP holds the test server with ACP handler support
type TestServerWithACP struct {
*TestServer
ACPHandler *acp.Handler
MessageStore *acp.MemoryMessageStore
}
// NewTestServerWithACP creates a test server with ACP handler for historical logs
func NewTestServerWithACP(t *testing.T) *TestServerWithACP {
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 (in-memory for tests)
taskRepo := repository.NewMemoryRepository()
// Initialize task service
taskSvc := taskservice.NewService(taskRepo, eventBus, log)
// Create ACP message store and handler
messageStore := acp.NewMemoryMessageStore(1000)
acpHandler := acp.NewHandler(messageStore, log)
// Create WebSocket gateway
gateway := gateways.NewGateway(log)
// Register handlers
taskWSHandlers := taskwshandlers.NewHandlers(taskSvc, log)
taskWSHandlers.RegisterHandlers(gateway.Dispatcher)
// Register a mock handler for task.logs that uses the ACP handler
gateway.Dispatcher.RegisterFunc(ws.ActionTaskLogs, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) {
var req struct {
TaskID string `json:"task_id"`
Limit int `json:"limit,omitempty"`
}
if err := msg.ParsePayload(&req); err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil)
}
if req.TaskID == "" {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id is required", nil)
}
// Get all historical messages from the store
messages, err := acpHandler.GetAllMessages(ctx, req.TaskID)
if err != nil {
return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to retrieve logs: "+err.Error(), nil)
}
// Apply limit if specified
if req.Limit > 0 && len(messages) > req.Limit {
messages = messages[len(messages)-req.Limit:]
}
type LogEntry struct {
Type string `json:"type"`
Timestamp string `json:"timestamp"`
AgentID string `json:"agent_id,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
logs := make([]LogEntry, 0, len(messages))
for _, m := range messages {
logs = append(logs, LogEntry{
Type: string(m.Type),
Timestamp: m.Timestamp.Format("2006-01-02T15:04:05Z"),
AgentID: m.AgentID,
Data: m.Data,
})
}
return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{
"task_id": req.TaskID,
"logs": logs,
"total": len(logs),
})
})
// Start hub
go gateway.Hub.Run(ctx)
// Set up historical logs provider for task subscriptions
gateway.Hub.SetHistoricalLogsProvider(func(ctx context.Context, taskID string) ([]*ws.Message, error) {
messages, err := acpHandler.GetAllMessages(ctx, taskID)
if err != nil {
return nil, err
}
// Convert protocol.Message to ws.Message notifications
result := make([]*ws.Message, 0, len(messages))
for _, msg := range messages {
action := "acp." + string(msg.Type)
notification, err := ws.NewNotification(action, map[string]interface{}{
"task_id": taskID,
"type": msg.Type,
"data": msg.Data,
"timestamp": msg.Timestamp,
})
if err != nil {
continue
}
result = append(result, notification)
}
return result, nil
})
// Create router
gin.SetMode(gin.TestMode)
router := gin.New()
gateway.SetupRoutes(router)
// Create test server
server := httptest.NewServer(router)
return &TestServerWithACP{
TestServer: &TestServer{
Server: server,
Gateway: gateway,
TaskRepo: taskRepo,
TaskSvc: taskSvc,
EventBus: eventBus,
Logger: log,
cancelFunc: cancel,
},
ACPHandler: acpHandler,
MessageStore: messageStore,
}
}
func TestTaskLogsAction(t *testing.T) {
ts := NewTestServerWithACP(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create a task first
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Logs Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Task With Logs",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
taskID := taskPayload["id"].(string)
ctx := context.Background()
// Test empty logs
t.Run("EmptyLogs", func(t *testing.T) {
resp, err := client.SendRequest("logs-1", ws.ActionTaskLogs, 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, taskID, payload["task_id"])
logs, ok := payload["logs"].([]interface{})
require.True(t, ok)
assert.Len(t, logs, 0)
assert.Equal(t, float64(0), payload["total"])
})
// Store some ACP messages
now := time.Now().UTC()
messages := []*protocol.Message{
{
Type: protocol.MessageTypeProgress,
Timestamp: now.Add(-3 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"progress": 25, "message": "Starting"},
},
{
Type: protocol.MessageTypeLog,
Timestamp: now.Add(-2 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "Processing file1.go", "level": "info"},
},
{
Type: protocol.MessageTypeProgress,
Timestamp: now.Add(-1 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"progress": 50, "message": "Halfway done"},
},
}
for _, msg := range messages {
err := ts.ACPHandler.ProcessMessage(ctx, msg)
require.NoError(t, err)
}
// Test retrieval with logs
t.Run("RetrieveLogs", func(t *testing.T) {
resp, err := client.SendRequest("logs-2", ws.ActionTaskLogs, 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, taskID, payload["task_id"])
logs, ok := payload["logs"].([]interface{})
require.True(t, ok)
assert.Len(t, logs, 3)
assert.Equal(t, float64(3), payload["total"])
// Verify logs are in chronological order
log0 := logs[0].(map[string]interface{})
log1 := logs[1].(map[string]interface{})
log2 := logs[2].(map[string]interface{})
assert.Equal(t, "progress", log0["type"])
assert.Equal(t, "log", log1["type"])
assert.Equal(t, "progress", log2["type"])
// Verify log entries contain required fields
assert.NotEmpty(t, log0["timestamp"])
assert.Equal(t, "agent-1", log0["agent_id"])
assert.NotNil(t, log0["data"])
})
// Test with limit parameter
t.Run("RetrieveLogsWithLimit", func(t *testing.T) {
resp, err := client.SendRequest("logs-3", ws.ActionTaskLogs, map[string]interface{}{
"task_id": taskID,
"limit": 2,
})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeResponse, resp.Type)
var payload map[string]interface{}
err = resp.ParsePayload(&payload)
require.NoError(t, err)
logs, ok := payload["logs"].([]interface{})
require.True(t, ok)
assert.Len(t, logs, 2)
assert.Equal(t, float64(2), payload["total"])
// Should return the last 2 logs (most recent)
log0 := logs[0].(map[string]interface{})
log1 := logs[1].(map[string]interface{})
assert.Equal(t, "log", log0["type"])
assert.Equal(t, "progress", log1["type"])
})
// Test validation - missing task_id
t.Run("MissingTaskID", func(t *testing.T) {
resp, err := client.SendRequest("logs-4", ws.ActionTaskLogs, map[string]interface{}{})
require.NoError(t, err)
assert.Equal(t, ws.MessageTypeError, resp.Type)
})
}
func TestHistoricalLogsOnSubscribe(t *testing.T) {
ts := NewTestServerWithACP(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create a task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Subscribe Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Subscribable Task With History",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
taskID := taskPayload["id"].(string)
ctx := context.Background()
// Store multiple ACP messages of different types
now := time.Now().UTC()
messages := []*protocol.Message{
{
Type: protocol.MessageTypeProgress,
Timestamp: now.Add(-4 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"progress": 10, "message": "Initializing"},
},
{
Type: protocol.MessageTypeLog,
Timestamp: now.Add(-3 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "Processing started", "level": "info"},
},
{
Type: protocol.MessageTypeProgress,
Timestamp: now.Add(-2 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"progress": 100, "message": "Complete"},
},
{
Type: protocol.MessageTypeResult,
Timestamp: now.Add(-1 * time.Second),
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"summary": "Task completed successfully", "files_changed": 5},
},
}
for _, msg := range messages {
err := ts.ACPHandler.ProcessMessage(ctx, msg)
require.NoError(t, err)
}
// Subscribe to the task and receive historical logs
t.Run("SubscribeReceivesHistoricalLogs", func(t *testing.T) {
// Use the existing client - the previous requests don't trigger notifications
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"])
// Collect historical log notifications - they are sent immediately after subscribe response
// Note: Some notifications may have been consumed by SendRequest while waiting for the response
receivedNotifications := make([]*ws.Message, 0, 4)
timeout := time.After(2 * time.Second)
collectLoop:
for {
select {
case msg := <-client.messages:
if msg.Type == ws.MessageTypeNotification &&
strings.HasPrefix(msg.Action, "acp.") {
receivedNotifications = append(receivedNotifications, msg)
if len(receivedNotifications) >= 4 {
break collectLoop
}
}
case <-timeout:
break collectLoop
}
}
// Verify we received at least some historical notifications
// Due to the way SendRequest consumes messages, we may not get all 4
require.GreaterOrEqual(t, len(receivedNotifications), 1, "Should receive at least 1 historical notification")
// Verify notifications have the correct action format (acp.{type})
for _, notification := range receivedNotifications {
assert.True(t, strings.HasPrefix(notification.Action, "acp."),
"Notification action should start with 'acp.', got: %s", notification.Action)
// Verify notification payload structure
var notifPayload map[string]interface{}
err = notification.ParsePayload(&notifPayload)
require.NoError(t, err)
assert.Equal(t, taskID, notifPayload["task_id"])
assert.NotNil(t, notifPayload["type"])
assert.NotNil(t, notifPayload["data"])
assert.NotNil(t, notifPayload["timestamp"])
}
// Unsubscribe
client.SendRequest("unsub-1", ws.ActionTaskUnsubscribe, map[string]interface{}{
"task_id": taskID,
})
})
}
func TestHistoricalLogsChronologicalOrder(t *testing.T) {
ts := NewTestServerWithACP(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create a task
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Chrono Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
columnID := colPayload["id"].(string)
taskResp, _ := client.SendRequest("task-1", ws.ActionTaskCreate, map[string]interface{}{
"board_id": boardID,
"column_id": columnID,
"title": "Chronological Order Test Task",
})
var taskPayload map[string]interface{}
taskResp.ParsePayload(&taskPayload)
taskID := taskPayload["id"].(string)
ctx := context.Background()
// Create messages in chronological order with specific timestamps
// In production, messages arrive in temporal order, so we test that they
// are returned in the same order they were stored
baseTime := time.Date(2025, 1, 15, 10, 0, 0, 0, time.UTC)
messagesInOrder := []*protocol.Message{
{
Type: protocol.MessageTypeProgress,
Timestamp: baseTime.Add(10 * time.Second), // 10:00:10
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "First message", "order": 1},
},
{
Type: protocol.MessageTypeLog,
Timestamp: baseTime.Add(20 * time.Second), // 10:00:20
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "Second message", "order": 2},
},
{
Type: protocol.MessageTypeLog,
Timestamp: baseTime.Add(30 * time.Second), // 10:00:30
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "Third message", "order": 3},
},
{
Type: protocol.MessageTypeResult,
Timestamp: baseTime.Add(40 * time.Second), // 10:00:40
AgentID: "agent-1",
TaskID: taskID,
Data: map[string]interface{}{"message": "Fourth message", "order": 4},
},
}
for _, msg := range messagesInOrder {
err := ts.ACPHandler.ProcessMessage(ctx, msg)
require.NoError(t, err)
}
// Retrieve logs via task.logs
t.Run("LogsChronologicalOrder", func(t *testing.T) {
resp, err := client.SendRequest("logs-chrono", ws.ActionTaskLogs, 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)
logs, ok := payload["logs"].([]interface{})
require.True(t, ok)
require.Len(t, logs, 4)
// Verify order matches chronological order (same as insertion order here)
for i, log := range logs {
logEntry := log.(map[string]interface{})
data := logEntry["data"].(map[string]interface{})
expectedOrder := float64(i + 1)
assert.Equal(t, expectedOrder, data["order"], "Log at index %d should have order %v", i, expectedOrder)
}
// Verify timestamps are in non-decreasing order
var prevTimestamp string
for i, log := range logs {
logEntry := log.(map[string]interface{})
currentTimestamp := logEntry["timestamp"].(string)
if i > 0 {
assert.True(t, currentTimestamp >= prevTimestamp,
"Timestamp at index %d should be >= timestamp at index %d", i, i-1)
}
prevTimestamp = currentTimestamp
}
})
}
// ============================================
// CONCURRENT REQUEST TESTS
// ============================================
func TestConcurrentRequests(t *testing.T) {
ts := NewTestServer(t)
defer ts.Close()
client := NewWSClient(t, ts.Server.URL)
defer client.Close()
// Create board and column
boardResp, _ := client.SendRequest("board-1", ws.ActionBoardCreate, map[string]interface{}{
"name": "Concurrent Test Board",
})
var boardPayload map[string]interface{}
boardResp.ParsePayload(&boardPayload)
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{}
colResp.ParsePayload(&colPayload)
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{}{
"board_id": boardID,
"column_id": columnID,
"title": "Concurrent Task " + string(rune('0'+idx)),
},
)
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)
}