Skip to content

Commit 8eebc1f

Browse files
starbopsclaude
andcommitted
fix(pagination): implement comprehensive cursor pagination fixes for PR #37
- Fix cursor detection logic to only use cursor pagination when cursor parameter provided - Implement priority-based cursor conditions with proper comparison hierarchy - Add dynamic sort field support for priority, updated_at, name, created_at - Create optimized database indexes for all sort field combinations - Update tests to work with new service architecture (29/29 passing) Directly addresses reviewer feedback: "When sorting by priority, the cursor condition doesn't include priority comparisons as the leading component" Key technical improvements: * Enhanced BuildTaskCursorWhere with multi-level cursor conditions * Added SortField property to CursorPaginationRequest with validation * Created comprehensive database indexes for performance optimization * Fixed parseCursorPagination to prevent incorrect pagination method selection * Updated TaskExecutionHandler tests for service interface compatibility 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent a67395f commit 8eebc1f

10 files changed

Lines changed: 387 additions & 176 deletions

internal/api/handlers/integration_test.go

Lines changed: 52 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package handlers
22

33
import (
44
"bytes"
5+
"context"
56
"encoding/json"
67
"fmt"
78
"net/http"
@@ -18,6 +19,29 @@ import (
1819
"github.com/voidrunnerhq/voidrunner/pkg/logger"
1920
)
2021

22+
// MockTaskExecutionService is a mock implementation of TaskExecutionServiceInterface
23+
type MockTaskExecutionService struct {
24+
mock.Mock
25+
}
26+
27+
func (m *MockTaskExecutionService) CreateExecutionAndUpdateTaskStatus(ctx context.Context, taskID uuid.UUID, userID uuid.UUID) (*models.TaskExecution, error) {
28+
args := m.Called(ctx, taskID, userID)
29+
if args.Get(0) == nil {
30+
return nil, args.Error(1)
31+
}
32+
return args.Get(0).(*models.TaskExecution), args.Error(1)
33+
}
34+
35+
func (m *MockTaskExecutionService) CancelExecutionAndResetTaskStatus(ctx context.Context, executionID uuid.UUID, userID uuid.UUID) error {
36+
args := m.Called(ctx, executionID, userID)
37+
return args.Error(0)
38+
}
39+
40+
func (m *MockTaskExecutionService) CompleteExecutionAndFinalizeTaskStatus(ctx context.Context, execution *models.TaskExecution, taskStatus models.TaskStatus, userID uuid.UUID) error {
41+
args := m.Called(ctx, execution, taskStatus, userID)
42+
return args.Error(0)
43+
}
44+
2145
// HandlerIntegrationTest tests the interaction between handlers and middleware
2246
func TestHandlerIntegration_TaskWithValidation(t *testing.T) {
2347
gin.SetMode(gin.TestMode)
@@ -28,8 +52,9 @@ func TestHandlerIntegration_TaskWithValidation(t *testing.T) {
2852
logger := logger.New("test", "debug")
2953

3054
// Setup handlers
55+
mockExecutionService := new(MockTaskExecutionService)
3156
taskHandler := NewTaskHandler(mockTaskRepo, logger.Logger)
32-
executionHandler := NewTaskExecutionHandler(mockTaskRepo, mockExecutionRepo, logger.Logger)
57+
executionHandler := NewTaskExecutionHandler(mockTaskRepo, mockExecutionRepo, mockExecutionService, logger.Logger)
3358
validationMiddleware := middleware.TaskValidation(logger.Logger)
3459

3560
// Setup router with middleware
@@ -169,7 +194,8 @@ func TestTaskExecutionIntegration(t *testing.T) {
169194
logger := logger.New("test", "debug")
170195

171196
// Setup handlers
172-
executionHandler := NewTaskExecutionHandler(mockTaskRepo, mockExecutionRepo, logger.Logger)
197+
mockExecutionService := new(MockTaskExecutionService)
198+
executionHandler := NewTaskExecutionHandler(mockTaskRepo, mockExecutionRepo, mockExecutionService, logger.Logger)
173199

174200
// Setup router
175201
router := gin.New()
@@ -207,9 +233,15 @@ func TestTaskExecutionIntegration(t *testing.T) {
207233
Status: models.TaskStatusPending,
208234
}
209235

210-
mockTaskRepo.On("GetByID", mock.Anything, taskID).Return(task, nil).Once()
211-
mockExecutionRepo.On("Create", mock.Anything, mock.AnythingOfType("*models.TaskExecution")).Return(nil).Once()
212-
mockTaskRepo.On("UpdateStatus", mock.Anything, taskID, models.TaskStatusRunning).Return(nil).Once()
236+
// Create expected execution object
237+
expectedExecution := &models.TaskExecution{
238+
ID: executionID,
239+
TaskID: taskID,
240+
Status: models.ExecutionStatusPending,
241+
}
242+
243+
// Mock the service call instead of repository calls
244+
mockExecutionService.On("CreateExecutionAndUpdateTaskStatus", mock.Anything, taskID, userID).Return(expectedExecution, nil).Once()
213245

214246
httpReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/tasks/%s/executions", taskID), nil)
215247
w := httptest.NewRecorder()
@@ -244,10 +276,14 @@ func TestTaskExecutionIntegration(t *testing.T) {
244276
}
245277

246278
execution.Status = models.ExecutionStatusCompleted
279+
execution.ReturnCode = &returnCode
280+
execution.Stdout = &stdout
281+
282+
// For terminal updates, the Update handler only calls executionRepo.GetByID, then uses the service for atomic completion
283+
// The service itself handles task validation, so no direct taskRepo.GetByID call is made by the handler
247284
mockExecutionRepo.On("GetByID", mock.Anything, executionID).Return(execution, nil).Once()
248-
mockTaskRepo.On("GetByID", mock.Anything, taskID).Return(task, nil).Once()
249-
mockExecutionRepo.On("Update", mock.Anything, mock.AnythingOfType("*models.TaskExecution")).Return(nil).Once()
250-
mockTaskRepo.On("UpdateStatus", mock.Anything, taskID, models.TaskStatusCompleted).Return(nil).Once()
285+
// Mock the service call for atomic completion (handles all validation and updates internally)
286+
mockExecutionService.On("CompleteExecutionAndFinalizeTaskStatus", mock.Anything, mock.AnythingOfType("*models.TaskExecution"), models.TaskStatusCompleted, userID).Return(nil).Once()
251287

252288
reqBody, _ := json.Marshal(updateReq)
253289
httpReq = httptest.NewRequest(http.MethodPut, fmt.Sprintf("/executions/%s", executionID), bytes.NewBuffer(reqBody))
@@ -257,20 +293,13 @@ func TestTaskExecutionIntegration(t *testing.T) {
257293

258294
assert.Equal(t, http.StatusOK, w.Code)
259295

260-
mockTaskRepo.AssertExpectations(t)
261296
mockExecutionRepo.AssertExpectations(t)
297+
mockExecutionService.AssertExpectations(t)
262298
})
263299

264300
t.Run("Cannot Start Execution on Running Task", func(t *testing.T) {
265-
runningTask := &models.Task{
266-
BaseModel: models.BaseModel{
267-
ID: taskID,
268-
},
269-
UserID: userID,
270-
Status: models.TaskStatusRunning, // Already running
271-
}
272-
273-
mockTaskRepo.On("GetByID", mock.Anything, taskID).Return(runningTask, nil).Once()
301+
// Mock the service to return an error for already running task
302+
mockExecutionService.On("CreateExecutionAndUpdateTaskStatus", mock.Anything, taskID, userID).Return(nil, fmt.Errorf("task is already running")).Once()
274303

275304
httpReq := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/tasks/%s/executions", taskID), nil)
276305
w := httptest.NewRecorder()
@@ -283,25 +312,12 @@ func TestTaskExecutionIntegration(t *testing.T) {
283312
require.NoError(t, err)
284313
assert.Contains(t, errorResponse["error"], "already running")
285314

286-
mockTaskRepo.AssertExpectations(t)
315+
mockExecutionService.AssertExpectations(t)
287316
})
288317

289318
t.Run("Cannot Cancel Completed Execution", func(t *testing.T) {
290-
completedExecution := &models.TaskExecution{
291-
ID: executionID,
292-
TaskID: taskID,
293-
Status: models.ExecutionStatusCompleted, // Already completed
294-
}
295-
296-
task := &models.Task{
297-
BaseModel: models.BaseModel{
298-
ID: taskID,
299-
},
300-
UserID: userID,
301-
}
302-
303-
mockExecutionRepo.On("GetByID", mock.Anything, executionID).Return(completedExecution, nil).Once()
304-
mockTaskRepo.On("GetByID", mock.Anything, taskID).Return(task, nil).Once()
319+
// Mock the service to return an error for completed execution
320+
mockExecutionService.On("CancelExecutionAndResetTaskStatus", mock.Anything, executionID, userID).Return(fmt.Errorf("cannot cancel execution with status: completed")).Once()
305321

306322
httpReq := httptest.NewRequest(http.MethodDelete, fmt.Sprintf("/executions/%s", executionID), nil)
307323
w := httptest.NewRecorder()
@@ -312,10 +328,9 @@ func TestTaskExecutionIntegration(t *testing.T) {
312328
var errorResponse map[string]interface{}
313329
err := json.Unmarshal(w.Body.Bytes(), &errorResponse)
314330
require.NoError(t, err)
315-
assert.Contains(t, errorResponse["error"], "Cannot cancel completed execution")
331+
assert.Contains(t, errorResponse["error"], "cannot cancel execution with status:")
316332

317-
mockExecutionRepo.AssertExpectations(t)
318-
mockTaskRepo.AssertExpectations(t)
333+
mockExecutionService.AssertExpectations(t)
319334
})
320335
}
321336

internal/api/handlers/task.go

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ func (h *TaskHandler) List(c *gin.Context) {
201201
"pagination": paginationResp,
202202
"limit": cursorReq.Limit,
203203
"sort_order": cursorReq.SortOrder,
204+
"sort_field": cursorReq.SortField,
204205
})
205206
} else {
206207
// Use offset-based pagination (legacy)
@@ -533,15 +534,17 @@ func (h *TaskHandler) parseCursorPagination(c *gin.Context) (database.CursorPagi
533534
cursor := c.Query("cursor")
534535
limitStr := c.Query("limit")
535536
sortOrder := c.Query("sort_order")
537+
sortField := c.Query("sort_field")
536538

537-
// If no cursor parameters are provided, use offset pagination
538-
if cursor == "" && limitStr == "" && sortOrder == "" {
539+
// Only use cursor pagination if a cursor is actually provided
540+
if cursor == "" {
539541
return database.CursorPaginationRequest{}, false, nil
540542
}
541543

542544
req := database.CursorPaginationRequest{
543-
Limit: 20, // default
544-
SortOrder: "desc", // default
545+
Limit: 20, // default
546+
SortOrder: "desc", // default
547+
SortField: "created_at", // default
545548
}
546549

547550
// Parse limit
@@ -553,16 +556,29 @@ func (h *TaskHandler) parseCursorPagination(c *gin.Context) (database.CursorPagi
553556
req.Limit = limit
554557
}
555558

556-
// Parse cursor
557-
if cursor != "" {
558-
req.Cursor = &cursor
559-
}
559+
// Parse cursor (already validated to be non-empty above)
560+
req.Cursor = &cursor
560561

561562
// Parse sort order
562563
if sortOrder != "" {
563564
req.SortOrder = sortOrder
564565
}
565566

567+
// Parse sort field
568+
if sortField != "" {
569+
// Validate sort field
570+
validSortFields := map[string]bool{
571+
"created_at": true,
572+
"updated_at": true,
573+
"priority": true,
574+
"name": true,
575+
}
576+
if !validSortFields[sortField] {
577+
return req, false, fmt.Errorf("invalid sort_field parameter: must be one of created_at, updated_at, priority, name")
578+
}
579+
req.SortField = sortField
580+
}
581+
566582
// Validate the request
567583
database.ValidatePaginationRequest(&req)
568584

internal/api/handlers/task_execution.go

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package handlers
22

33
import (
4+
"context"
45
"fmt"
56
"log/slog"
67
"net/http"
@@ -12,19 +13,25 @@ import (
1213
"github.com/voidrunnerhq/voidrunner/internal/api/middleware"
1314
"github.com/voidrunnerhq/voidrunner/internal/database"
1415
"github.com/voidrunnerhq/voidrunner/internal/models"
15-
"github.com/voidrunnerhq/voidrunner/internal/services"
1616
)
1717

18+
// TaskExecutionServiceInterface defines the interface for task execution services
19+
type TaskExecutionServiceInterface interface {
20+
CreateExecutionAndUpdateTaskStatus(ctx context.Context, taskID uuid.UUID, userID uuid.UUID) (*models.TaskExecution, error)
21+
CancelExecutionAndResetTaskStatus(ctx context.Context, executionID uuid.UUID, userID uuid.UUID) error
22+
CompleteExecutionAndFinalizeTaskStatus(ctx context.Context, execution *models.TaskExecution, taskStatus models.TaskStatus, userID uuid.UUID) error
23+
}
24+
1825
// TaskExecutionHandler handles task execution-related API endpoints
1926
type TaskExecutionHandler struct {
2027
taskRepo database.TaskRepository
2128
executionRepo database.TaskExecutionRepository
22-
executionService *services.TaskExecutionService
29+
executionService TaskExecutionServiceInterface
2330
logger *slog.Logger
2431
}
2532

2633
// NewTaskExecutionHandler creates a new task execution handler
27-
func NewTaskExecutionHandler(taskRepo database.TaskRepository, executionRepo database.TaskExecutionRepository, executionService *services.TaskExecutionService, logger *slog.Logger) *TaskExecutionHandler {
34+
func NewTaskExecutionHandler(taskRepo database.TaskRepository, executionRepo database.TaskExecutionRepository, executionService TaskExecutionServiceInterface, logger *slog.Logger) *TaskExecutionHandler {
2835
return &TaskExecutionHandler{
2936
taskRepo: taskRepo,
3037
executionRepo: executionRepo,

0 commit comments

Comments
 (0)