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{}