Files
kandev/apps/backend/internal/agent/handlers/git_handlers_test.go
T
2026-05-16 20:07:57 +01:00

595 lines
17 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"time"
"github.com/kandev/kandev/internal/agent/runtime/agentctl"
"github.com/kandev/kandev/internal/agent/runtime/lifecycle"
ws "github.com/kandev/kandev/pkg/websocket"
)
// mockExecutionLookup implements ExecutionLookup for testing.
type mockExecutionLookup struct {
executions map[string]*lifecycle.AgentExecution
// ensureErr allows tests to inject specific errors from GetOrEnsureExecution
ensureErr error
}
func (m *mockExecutionLookup) GetExecutionBySessionID(sessionID string) (*lifecycle.AgentExecution, bool) {
if m.executions == nil {
return nil, false
}
exec, ok := m.executions[sessionID]
return exec, ok
}
func (m *mockExecutionLookup) GetOrEnsureExecution(_ context.Context, sessionID string) (*lifecycle.AgentExecution, error) {
// Return injected error if set
if m.ensureErr != nil {
return nil, m.ensureErr
}
if m.executions == nil {
return nil, fmt.Errorf("no execution for session %s", sessionID)
}
exec, ok := m.executions[sessionID]
if !ok {
return nil, fmt.Errorf("no execution for session %s", sessionID)
}
return exec, nil
}
// mockSessionReader implements SessionReader for testing.
type mockSessionReader struct {
baseCommits map[string]string
baseBranches map[string]string
}
func (m *mockSessionReader) GetSessionBaseCommit(_ context.Context, sessionID string) string {
if m.baseCommits == nil {
return ""
}
return m.baseCommits[sessionID]
}
func (m *mockSessionReader) GetSessionBaseBranch(_ context.Context, sessionID string) string {
if m.baseBranches == nil {
return ""
}
return m.baseBranches[sessionID]
}
func TestNewGitHandlers(t *testing.T) {
log := newTestLogger()
lookup := &mockExecutionLookup{}
reader := &mockSessionReader{}
h := NewGitHandlers(lookup, reader, log)
if h == nil {
t.Fatal("expected non-nil handlers")
}
if h.lifecycleMgr != lookup {
t.Error("expected lifecycleMgr to match")
}
if h.sessionReader != reader {
t.Error("expected sessionReader to match")
}
}
func TestNewGitHandlers_NilDependencies(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(nil, nil, log)
if h == nil {
t.Fatal("expected non-nil handlers")
}
}
func TestRegisterGitHandlers(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(nil, nil, log)
d := ws.NewDispatcher()
h.RegisterHandlers(d)
actions := []string{
ws.ActionWorktreePull,
ws.ActionWorktreePush,
ws.ActionWorktreeRebase,
ws.ActionWorktreeMerge,
ws.ActionWorktreeAbort,
ws.ActionWorktreeCommit,
ws.ActionWorktreeStage,
ws.ActionWorktreeUnstage,
ws.ActionWorktreeDiscard,
ws.ActionWorktreeCreatePR,
ws.ActionWorktreeRevertCommit,
ws.ActionWorktreeRenameBranch,
ws.ActionWorktreeReset,
ws.ActionSessionCommitDiff,
ws.ActionSessionGitCommits,
ws.ActionSessionCumulativeDiff,
}
for _, action := range actions {
if !d.HasHandler(action) {
t.Errorf("expected handler registered for %s", action)
}
}
}
func TestNotifyGitOperationFailed_NilResult(t *testing.T) {
log := newTestLogger()
called := false
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
called = true
})
// Should not panic or call callback
h.notifyGitOperationFailed("session-1", "commit", nil)
if called {
t.Error("callback should not be called for nil result")
}
}
func TestNotifyGitOperationFailed_SuccessResult(t *testing.T) {
log := newTestLogger()
called := false
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
called = true
})
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
Success: true,
})
if called {
t.Error("callback should not be called for successful result")
}
}
func TestNotifyGitOperationFailed_NilCallback(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
// No callback set — should not panic
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
Success: false,
Error: "pre-commit hook failed",
})
}
func TestNotifyGitOperationFailed_NoExecution(t *testing.T) {
log := newTestLogger()
called := false
h := NewGitHandlers(&mockExecutionLookup{}, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
called = true
})
h.notifyGitOperationFailed("unknown-session", "commit", &client.GitOperationResult{
Success: false,
Error: "failed",
})
// Give the goroutine a moment (it shouldn't fire)
time.Sleep(50 * time.Millisecond)
if called {
t.Error("callback should not be called when no execution found")
}
}
func TestNotifyGitOperationFailed_EmptyTaskID(t *testing.T) {
log := newTestLogger()
called := false
lookup := &mockExecutionLookup{
executions: map[string]*lifecycle.AgentExecution{
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: ""},
},
}
h := NewGitHandlers(lookup, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, _ string) {
called = true
})
h.notifyGitOperationFailed("session-1", "push", &client.GitOperationResult{
Success: false,
Error: "rejected",
})
time.Sleep(50 * time.Millisecond)
if called {
t.Error("callback should not be called when task ID is empty")
}
}
func TestNotifyGitOperationFailed_UsesErrorField(t *testing.T) {
log := newTestLogger()
var mu sync.Mutex
var gotOperation, gotErrorOutput string
lookup := &mockExecutionLookup{
executions: map[string]*lifecycle.AgentExecution{
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: "task-1"},
},
}
h := NewGitHandlers(lookup, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, operation, errorOutput string) {
mu.Lock()
defer mu.Unlock()
gotOperation = operation
gotErrorOutput = errorOutput
})
h.notifyGitOperationFailed("session-1", "commit", &client.GitOperationResult{
Success: false,
Error: "pre-commit hook failed",
Output: "some output",
})
// Wait for async callback
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if gotOperation != "commit" {
t.Errorf("expected operation 'commit', got %q", gotOperation)
}
if gotErrorOutput != "pre-commit hook failed" {
t.Errorf("expected error output 'pre-commit hook failed', got %q", gotErrorOutput)
}
}
func TestNotifyGitOperationFailed_FallsBackToOutput(t *testing.T) {
log := newTestLogger()
var mu sync.Mutex
var gotErrorOutput string
lookup := &mockExecutionLookup{
executions: map[string]*lifecycle.AgentExecution{
"session-1": {ID: "exec-1", SessionID: "session-1", TaskID: "task-1"},
},
}
h := NewGitHandlers(lookup, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, _, _, _, errorOutput string) {
mu.Lock()
defer mu.Unlock()
gotErrorOutput = errorOutput
})
h.notifyGitOperationFailed("session-1", "push", &client.GitOperationResult{
Success: false,
Error: "",
Output: "fatal: rejected",
})
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if gotErrorOutput != "fatal: rejected" {
t.Errorf("expected fallback to Output field, got %q", gotErrorOutput)
}
}
func TestNotifyGitOperationFailed_PassesSessionAndTaskID(t *testing.T) {
log := newTestLogger()
var mu sync.Mutex
var gotSessionID, gotTaskID string
lookup := &mockExecutionLookup{
executions: map[string]*lifecycle.AgentExecution{
"sess-42": {ID: "exec-1", SessionID: "sess-42", TaskID: "task-99"},
},
}
h := NewGitHandlers(lookup, nil, log)
h.SetOnGitOperationFailed(func(_ context.Context, sessionID, taskID, _, _ string) {
mu.Lock()
defer mu.Unlock()
gotSessionID = sessionID
gotTaskID = taskID
})
h.notifyGitOperationFailed("sess-42", "rebase", &client.GitOperationResult{
Success: false,
Error: "conflict",
})
time.Sleep(100 * time.Millisecond)
mu.Lock()
defer mu.Unlock()
if gotSessionID != "sess-42" {
t.Errorf("expected sessionID 'sess-42', got %q", gotSessionID)
}
if gotTaskID != "task-99" {
t.Errorf("expected taskID 'task-99', got %q", gotTaskID)
}
}
func TestWsPull_InvalidPayload(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(nil, nil, log)
msg := &ws.Message{
ID: "test-1",
Action: ws.ActionWorktreePull,
Payload: json.RawMessage(`{invalid`),
}
_, err := h.wsPull(context.Background(), msg)
if err == nil {
t.Error("expected error for invalid payload")
}
}
func TestWsPull_MissingSessionID(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(nil, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreePull, GitPullRequest{SessionID: ""})
_, err := h.wsPull(context.Background(), msg)
if err == nil {
t.Error("expected error for missing session_id")
}
}
func TestWsCommit_MissingMessage(t *testing.T) {
log := newTestLogger()
h := NewGitHandlers(nil, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreeCommit, GitCommitRequest{
SessionID: "session-1",
Message: "",
})
_, err := h.wsCommit(context.Background(), msg)
if err == nil {
t.Error("expected error for missing message")
}
}
func TestWsPush_NoExecution(t *testing.T) {
log := newTestLogger()
lookup := &mockExecutionLookup{}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionWorktreePush, GitPushRequest{SessionID: "session-1"})
_, err := h.wsPush(context.Background(), msg)
if err == nil {
t.Error("expected error when no execution found")
}
}
func TestWsGitCommits_NotReady(t *testing.T) {
log := newTestLogger()
// Use "no agent running for session" error which matches isSessionNotReadyError
lookup := &mockExecutionLookup{
ensureErr: fmt.Errorf("no agent running for session session-1"),
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionGitCommits, GitCommitsRequest{SessionID: "session-1"})
resp, err := h.wsGitCommits(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
commits, ok := payload["commits"].([]any)
if !ok {
t.Fatalf("expected commits array, got %T", payload["commits"])
}
if len(commits) != 0 {
t.Errorf("expected empty commits, got %d", len(commits))
}
}
func TestWsCumulativeDiff_NotReady(t *testing.T) {
log := newTestLogger()
// Use "no agent running for session" error which matches isSessionNotReadyError
lookup := &mockExecutionLookup{
ensureErr: fmt.Errorf("no agent running for session session-1"),
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"})
resp, err := h.wsCumulativeDiff(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
if payload["cumulative_diff"] != nil {
t.Errorf("expected cumulative_diff=nil, got %v", payload["cumulative_diff"])
}
}
func TestWsCumulativeDiff_WorkspaceNotReady(t *testing.T) {
log := newTestLogger()
lookup := &mockExecutionLookup{
ensureErr: lifecycle.ErrSessionWorkspaceNotReady,
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"})
resp, err := h.wsCumulativeDiff(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response for workspace not ready, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
if payload["cumulative_diff"] != nil {
t.Errorf("expected cumulative_diff=nil, got %v", payload["cumulative_diff"])
}
}
func TestWsCumulativeDiff_NilAgentClient(t *testing.T) {
log := newTestLogger()
// Execution exists but has no agentctl client yet
lookup := &mockExecutionLookup{
executions: map[string]*lifecycle.AgentExecution{
"session-1": {ID: "exec-1", SessionID: "session-1"},
},
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"})
resp, err := h.wsCumulativeDiff(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response for nil agent client, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
if payload["cumulative_diff"] != nil {
t.Errorf("expected cumulative_diff=nil, got %v", payload["cumulative_diff"])
}
}
func TestWsCumulativeDiff_UnexpectedError(t *testing.T) {
log := newTestLogger()
lookup := &mockExecutionLookup{
ensureErr: errors.New("database connection failed"),
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionCumulativeDiff, CumulativeDiffRequest{SessionID: "session-1"})
_, err := h.wsCumulativeDiff(context.Background(), msg)
if err == nil {
t.Fatal("expected error for unexpected failure")
}
}
func TestIsSessionNotReadyError(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil error", nil, false},
{"no agent running", fmt.Errorf("no agent running for session abc"), true},
{"agent client not available", fmt.Errorf("agent client not available for session abc"), true},
{"unrelated error", fmt.Errorf("some other error"), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isSessionNotReadyError(tt.err); got != tt.want {
t.Errorf("isSessionNotReadyError() = %v, want %v", got, tt.want)
}
})
}
}
func TestWsGitCommits_WorkspaceNotReadyError(t *testing.T) {
// Test that ErrSessionWorkspaceNotReady returns ready:false gracefully
log := newTestLogger()
lookup := &mockExecutionLookup{
ensureErr: lifecycle.ErrSessionWorkspaceNotReady,
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionGitCommits, GitCommitsRequest{SessionID: "session-1"})
resp, err := h.wsGitCommits(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response for workspace not ready, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
}
func TestWsGitCommits_UnexpectedError(t *testing.T) {
// Test that unexpected errors (not workspace-not-ready) are returned as errors
log := newTestLogger()
lookup := &mockExecutionLookup{
ensureErr: errors.New("database connection failed"),
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionGitCommits, GitCommitsRequest{SessionID: "session-1"})
_, err := h.wsGitCommits(context.Background(), msg)
if err == nil {
t.Fatal("expected error for unexpected failure")
}
if !errors.Is(err, lookup.ensureErr) && err.Error() != "failed to get execution for session session-1: database connection failed" {
t.Errorf("expected wrapped database error, got: %v", err)
}
}
func TestWsGitCommits_WrappedWorkspaceNotReadyError(t *testing.T) {
// Test that wrapped ErrSessionWorkspaceNotReady is still detected
log := newTestLogger()
lookup := &mockExecutionLookup{
ensureErr: fmt.Errorf("some context: %w", lifecycle.ErrSessionWorkspaceNotReady),
}
h := NewGitHandlers(lookup, nil, log)
msg, _ := ws.NewRequest("test-1", ws.ActionSessionGitCommits, GitCommitsRequest{SessionID: "session-1"})
resp, err := h.wsGitCommits(context.Background(), msg)
if err != nil {
t.Fatalf("expected graceful response for wrapped workspace not ready, got error: %v", err)
}
if resp == nil {
t.Fatal("expected non-nil response")
}
var payload map[string]any
if err := json.Unmarshal(resp.Payload, &payload); err != nil {
t.Fatalf("failed to parse payload: %v", err)
}
if ready, ok := payload["ready"].(bool); !ok || ready {
t.Errorf("expected ready=false, got %v", payload["ready"])
}
}