Files
kandev/apps/backend/internal/task/handlers/errors_test.go
T
2026-09-06 15:19:25 +01:00

272 lines
10 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"net"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite"
"github.com/kandev/kandev/internal/task/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/internal/worktree"
ws "github.com/kandev/kandev/pkg/websocket"
)
// TestErrorsAreClassifiable verifies that the package's error-classification
// helpers detect typed sentinels through any depth of wrapping, so HTTP
// status mapping survives downstream wrap/unwrap changes.
func TestErrorsAreClassifiable(t *testing.T) {
notFoundSentinels := map[string]error{
"taskrepo.ErrTaskNotFound": taskrepo.ErrTaskNotFound,
"taskrepo.ErrWorkspaceNotFound": taskrepo.ErrWorkspaceNotFound,
"taskrepo.ErrNoPrimarySession": taskrepo.ErrNoPrimarySession,
"service.ErrDocumentNotFound": service.ErrDocumentNotFound,
"service.ErrTaskPlanNotFound": service.ErrTaskPlanNotFound,
"service.ErrRevisionNotFound": service.ErrRevisionNotFound,
}
for name, sentinel := range notFoundSentinels {
t.Run("isNotFound recognizes "+name, func(t *testing.T) {
wrapped := fmt.Errorf("look up failed: %w", sentinel)
if !isNotFound(wrapped) {
t.Errorf("expected wrapped %s to classify as not-found", name)
}
})
}
t.Run("isAgentReportedError uses lifecycle.ErrAgentReported", func(t *testing.T) {
wrapped := fmt.Errorf("waitForPromptDone: %w", lifecycle.ErrAgentReported)
if !isAgentReportedError(wrapped) {
t.Errorf("expected wrapped ErrAgentReported to classify")
}
if isAgentReportedError(errors.New("agent error: not the sentinel")) {
t.Errorf("untyped lookalike must no longer classify")
}
})
// A browser that navigates away or unmounts a component aborts its
// in-flight fetches. That reaches handlers as a cancelled request context,
// which used to fall through to the 500 branch and log an ERROR with a
// full stacktrace for a response nobody was waiting on any more.
t.Run("client disconnect is not a server error", func(t *testing.T) {
gin.SetMode(gin.TestMode)
handlers := map[string]func(*gin.Context, error){
"handleNotFound": func(ctx *gin.Context, err error) {
handleNotFound(ctx, newTestLogger(t), err, "task sessions not found")
},
"handleSelectedMoveError": func(ctx *gin.Context, err error) {
handleSelectedMoveError(ctx, newTestLogger(t), err)
},
}
for name, handle := range handlers {
t.Run(name, func(t *testing.T) {
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
handle(ctx, fmt.Errorf("list task sessions: %w", context.Canceled))
if rec.Code != statusClientClosedRequest {
t.Fatalf("status=%d, want %d", rec.Code, statusClientClosedRequest)
}
})
}
})
// Our own deadline firing is a real failure and must keep its 500.
t.Run("server timeout stays a server error", func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
handleNotFound(ctx, newTestLogger(t), fmt.Errorf("query: %w", context.DeadlineExceeded), "task not found")
if rec.Code != http.StatusInternalServerError {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusInternalServerError)
}
})
t.Run("WIP limit is a conflict for task creation", func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
handleNotFound(ctx, newTestLogger(t), fmt.Errorf("create task: %w", wfmodels.NewWIPLimitError("review", 2, 2)), "task not created")
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusConflict)
}
})
t.Run("dirty worktree delete returns a typed conflict", func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
err := &service.TaskDeleteDirtyWorktreeError{DirtyWorktrees: []worktree.DirtyWorktree{{
WorktreeID: "wt-1", RepositoryID: "repo-1", Path: "/tasks/task-1/repo",
DirtyFiles: []string{"notes.txt"},
}}}
handleNotFound(ctx, newTestLogger(t), fmt.Errorf("delete task: %w", err), "task not deleted")
if rec.Code != http.StatusConflict {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusConflict)
}
var payload struct {
ErrorCode string `json:"error_code"`
DirtyWorktrees []worktree.DirtyWorktree `json:"dirty_worktrees"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload.ErrorCode != service.TaskDeleteDirtyWorktreeErrorCode {
t.Fatalf("error_code=%q, want %q", payload.ErrorCode, service.TaskDeleteDirtyWorktreeErrorCode)
}
if len(payload.DirtyWorktrees) != 1 || payload.DirtyWorktrees[0].DirtyFiles[0] != "notes.txt" {
t.Fatalf("dirty_worktrees=%+v, want the inspected file", payload.DirtyWorktrees)
}
})
t.Run("stale branch policies include a stable error code", func(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
err := fmt.Errorf("%w: %w", service.ErrInvalidRepositoryBranchPolicy, service.ErrRepositoryBranchPolicyStale)
handleNotFound(ctx, newTestLogger(t), err, "task not created")
if rec.Code != http.StatusBadRequest {
t.Fatalf("status=%d, want %d", rec.Code, http.StatusBadRequest)
}
var payload map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["error_code"] != service.BranchPolicyStaleErrorCode {
t.Fatalf("error_code=%q, want %q", payload["error_code"], service.BranchPolicyStaleErrorCode)
}
})
}
func TestTaskCreateRepositorySelectionErrorsMapToTransports(t *testing.T) {
tests := []struct {
name string
errorCode service.RepositorySelectionErrorCode
httpStatus int
wsCode string
}{
{
name: "invalid",
errorCode: service.RepositorySelectionErrorInvalid,
httpStatus: http.StatusBadRequest,
wsCode: ws.ErrorCodeValidation,
},
{
name: "not found",
errorCode: service.RepositorySelectionErrorNotFound,
httpStatus: http.StatusNotFound,
wsCode: ws.ErrorCodeNotFound,
},
{
name: "unavailable",
errorCode: service.RepositorySelectionErrorUnavailable,
httpStatus: http.StatusServiceUnavailable,
wsCode: ws.ErrorCodeUnavailable,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
err := service.NewRepositorySelectionError(test.errorCode, errors.New("plugin response secret"))
status, ok := repositorySelectionHTTPStatus(err)
if !ok || status != test.httpStatus {
t.Fatalf("repositorySelectionHTTPStatus() = (%d, %t), want (%d, true)", status, ok, test.httpStatus)
}
code, ok := repositorySelectionWSCode(err)
if !ok || code != test.wsCode {
t.Fatalf("repositorySelectionWSCode() = (%q, %t), want (%q, true)", code, ok, test.wsCode)
}
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
handleNotFound(ctx, newTestLogger(t), err, "task not created")
if rec.Code != test.httpStatus {
t.Fatalf("HTTP status = %d, want %d", rec.Code, test.httpStatus)
}
var payload map[string]string
if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode HTTP response: %v", err)
}
if payload["error_code"] != string(test.errorCode) {
t.Fatalf("error_code = %q, want %q", payload["error_code"], test.errorCode)
}
if strings.Contains(rec.Body.String(), "plugin response secret") {
t.Fatal("HTTP response leaked the wrapped provider error")
}
})
}
}
func TestMoveConflictCode(t *testing.T) {
cases := []struct {
name string
err string
want string
}{
{"active session", "task has an active session (running)", moveConflictCodeActiveSession},
{"archived task", "archived tasks cannot be moved", moveConflictCodeArchived},
{"different workspace", "target workflow is in a different workspace", moveConflictCodeDifferentWorkspace},
{"workflow step", "target workflow step does not belong to target workflow", moveConflictCodeWorkflowStep},
{"WIP limit", "WIP limit exceeded for workflow step", moveConflictCodeWIPLimit},
{"unrelated", "database is locked", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := moveConflictCode(errors.New(tc.err)); got != tc.want {
t.Fatalf("moveConflictCode(%q) = %q, want %q", tc.err, got, tc.want)
}
})
}
}
// TestIsTimeoutError pins the UX-classification contract for the prompt
// error-message renderer. The pre-refactor substring check on "timeout"
// covered three classes of producer; the typed-sentinel rewrite must keep
// all three classified or the user-facing "Request timed out…" message
// silently downgrades to the generic "Failed to send message to agent".
func TestIsTimeoutError(t *testing.T) {
cases := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"unrelated", errors.New("something else"), false},
// net.Error with Timeout()==true (typed) — e.g. http.Client deadline.
{"net error timeout", &timeoutNetErr{}, true},
// Plain fmt.Errorf("timeout …") from waitForSessionReady, agentctl
// health waits, agent-stream connect waits. No typed Timeout() method.
{"session-ready timeout (substring fallback)", errors.New("timeout waiting for session to become ready after resume"), true},
{"agent-stream timeout (substring fallback)", errors.New("timeout waiting for agent stream to connect after restart"), true},
// context.DeadlineExceeded is itself a net.Error with Timeout()==true,
// so it matches via the typed path — no separate errors.Is needed in
// createPromptErrorMessage.
{"context.DeadlineExceeded", context.DeadlineExceeded, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isTimeoutError(tc.err); got != tc.want {
t.Errorf("isTimeoutError(%v) = %v, want %v", tc.err, got, tc.want)
}
})
}
}
type timeoutNetErr struct{}
func (timeoutNetErr) Error() string { return "i/o timeout" }
func (timeoutNetErr) Timeout() bool { return true }
func (timeoutNetErr) Temporary() bool { return false }
var _ net.Error = timeoutNetErr{}