Phase 1 complete: coverage from 70.1% to 85.6% (130+ tests, all passing). New test files: - lifecycle_test.go: full claim -> process -> complete integration flows - allowance_edge_test.go: boundary conditions for token/job/concurrent limits - allowance_error_test.go: mock errorStore covering all RecordUsage error paths - embed_test.go: Prompt() hit/miss and content trimming - service_test.go: DefaultServiceOptions, TaskPrompt, TaskCommit type coverage - completion_git_test.go: real git repos for AutoCommit, CreateBranch, CommitAndSync - context_git_test.go: findRelatedCode with keyword search, file limits, truncation Updated config_test.go with YAML fallback, env override, and empty-dir paths. Co-Authored-By: Charon <developers@lethean.io>
474 lines
14 KiB
Go
474 lines
14 KiB
Go
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// initGitRepo creates a temporary git repo with an initial commit.
|
|
func initGitRepo(t *testing.T) string {
|
|
t.Helper()
|
|
dir := t.TempDir()
|
|
|
|
// Initialise a git repo.
|
|
_, err := runCommandCtx(context.Background(), dir, "git", "init")
|
|
require.NoError(t, err, "git init should succeed")
|
|
|
|
// Configure git identity for commits.
|
|
_, err = runCommandCtx(context.Background(), dir, "git", "config", "user.email", "test@example.com")
|
|
require.NoError(t, err)
|
|
_, err = runCommandCtx(context.Background(), dir, "git", "config", "user.name", "Test User")
|
|
require.NoError(t, err)
|
|
|
|
// Create initial commit so HEAD exists.
|
|
readmePath := filepath.Join(dir, "README.md")
|
|
err = os.WriteFile(readmePath, []byte("# Test Repo\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
_, err = runCommandCtx(context.Background(), dir, "git", "add", "-A")
|
|
require.NoError(t, err)
|
|
_, err = runCommandCtx(context.Background(), dir, "git", "commit", "-m", "initial commit")
|
|
require.NoError(t, err)
|
|
|
|
return dir
|
|
}
|
|
|
|
// --- runCommandCtx / runGitCommandCtx tests ---
|
|
|
|
func TestRunCommandCtx_Good(t *testing.T) {
|
|
output, err := runCommandCtx(context.Background(), "/tmp", "echo", "hello world")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "hello world")
|
|
}
|
|
|
|
func TestRunCommandCtx_Bad_NonexistentCommand(t *testing.T) {
|
|
_, err := runCommandCtx(context.Background(), "/tmp", "nonexistent-command-xyz")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRunCommandCtx_Bad_CommandFails(t *testing.T) {
|
|
_, err := runCommandCtx(context.Background(), "/tmp", "false")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRunCommandCtx_Bad_StderrIncluded(t *testing.T) {
|
|
// git status in a non-git directory should produce stderr.
|
|
dir := t.TempDir()
|
|
_, err := runCommandCtx(context.Background(), dir, "git", "status")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func TestRunGitCommandCtx_Good(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
output, err := runGitCommandCtx(context.Background(), dir, "log", "--oneline", "-1")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "initial commit")
|
|
}
|
|
|
|
// --- GetCurrentBranch tests ---
|
|
|
|
func TestGetCurrentBranch_Good(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
branch, err := GetCurrentBranch(context.Background(), dir)
|
|
require.NoError(t, err)
|
|
// Depending on git config, default branch could be master or main.
|
|
assert.True(t, branch == "main" || branch == "master",
|
|
"expected main or master, got %q", branch)
|
|
}
|
|
|
|
func TestGetCurrentBranch_Bad_NotAGitRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
_, err := GetCurrentBranch(context.Background(), dir)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to get current branch")
|
|
}
|
|
|
|
// --- HasUncommittedChanges tests ---
|
|
|
|
func TestHasUncommittedChanges_Good_Clean(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
hasChanges, err := HasUncommittedChanges(context.Background(), dir)
|
|
require.NoError(t, err)
|
|
assert.False(t, hasChanges, "fresh repo with initial commit should be clean")
|
|
}
|
|
|
|
func TestHasUncommittedChanges_Good_WithChanges(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Create a new file.
|
|
err := os.WriteFile(filepath.Join(dir, "new-file.txt"), []byte("content"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
hasChanges, err := HasUncommittedChanges(context.Background(), dir)
|
|
require.NoError(t, err)
|
|
assert.True(t, hasChanges, "should detect untracked file")
|
|
}
|
|
|
|
func TestHasUncommittedChanges_Good_WithModifiedFile(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Modify the existing README.
|
|
err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Updated\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
hasChanges, err := HasUncommittedChanges(context.Background(), dir)
|
|
require.NoError(t, err)
|
|
assert.True(t, hasChanges, "should detect modified file")
|
|
}
|
|
|
|
func TestHasUncommittedChanges_Bad_NotAGitRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
_, err := HasUncommittedChanges(context.Background(), dir)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// --- GetDiff tests ---
|
|
|
|
func TestGetDiff_Good_Unstaged(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Modify a tracked file.
|
|
err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Modified\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
diff, err := GetDiff(context.Background(), dir, false)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, diff, "Modified", "diff should show the change")
|
|
}
|
|
|
|
func TestGetDiff_Good_Staged(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Modify and stage a file.
|
|
err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("# Staged change\n"), 0644)
|
|
require.NoError(t, err)
|
|
_, err = runCommandCtx(context.Background(), dir, "git", "add", "README.md")
|
|
require.NoError(t, err)
|
|
|
|
diff, err := GetDiff(context.Background(), dir, true)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, diff, "Staged change", "staged diff should show the change")
|
|
}
|
|
|
|
func TestGetDiff_Good_NoDiff(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
diff, err := GetDiff(context.Background(), dir, false)
|
|
require.NoError(t, err)
|
|
assert.Empty(t, diff, "clean repo should have no diff")
|
|
}
|
|
|
|
func TestGetDiff_Bad_NotAGitRepo(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
_, err := GetDiff(context.Background(), dir, false)
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
// --- AutoCommit tests (with real git) ---
|
|
|
|
func TestAutoCommit_Good(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Create a file to commit.
|
|
err := os.WriteFile(filepath.Join(dir, "feature.go"), []byte("package main\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
task := &Task{ID: "T-100", Title: "Add feature"}
|
|
err = AutoCommit(context.Background(), task, dir, "feat: add feature module")
|
|
require.NoError(t, err)
|
|
|
|
// Verify commit was created.
|
|
output, err := runGitCommandCtx(context.Background(), dir, "log", "--oneline", "-1")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "feat: add feature module")
|
|
|
|
// Verify task reference in full message.
|
|
fullLog, err := runGitCommandCtx(context.Background(), dir, "log", "-1", "--pretty=format:%B")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, fullLog, "Task: #T-100")
|
|
assert.Contains(t, fullLog, "Co-Authored-By: Claude <noreply@anthropic.com>")
|
|
}
|
|
|
|
func TestAutoCommit_Bad_NoChangesToCommit(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// No changes to commit.
|
|
task := &Task{ID: "T-200", Title: "No changes"}
|
|
err := AutoCommit(context.Background(), task, dir, "feat: nothing")
|
|
assert.Error(t, err, "should fail when there is nothing to commit")
|
|
}
|
|
|
|
// --- CreateBranch tests (with real git) ---
|
|
|
|
func TestCreateBranch_Good(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
task := &Task{
|
|
ID: "BR-42",
|
|
Title: "Implement new feature",
|
|
Labels: []string{"enhancement"},
|
|
}
|
|
|
|
branchName, err := CreateBranch(context.Background(), task, dir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "feat/BR-42-implement-new-feature", branchName)
|
|
|
|
// Verify we're on the new branch.
|
|
currentBranch, err := GetCurrentBranch(context.Background(), dir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, branchName, currentBranch)
|
|
}
|
|
|
|
func TestCreateBranch_Good_BugLabel(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
task := &Task{
|
|
ID: "BR-43",
|
|
Title: "Fix login bug",
|
|
Labels: []string{"bug"},
|
|
}
|
|
|
|
branchName, err := CreateBranch(context.Background(), task, dir)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "fix/BR-43-fix-login-bug", branchName)
|
|
}
|
|
|
|
// --- PushChanges test ---
|
|
|
|
func TestPushChanges_Bad_NoRemote(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// No remote configured, push should fail.
|
|
err := PushChanges(context.Background(), dir)
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "failed to push changes")
|
|
}
|
|
|
|
// --- CommitAndSync tests ---
|
|
|
|
func TestCommitAndSync_Good_WithoutClient(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Create a file to commit.
|
|
err := os.WriteFile(filepath.Join(dir, "sync.go"), []byte("package sync\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
task := &Task{ID: "CS-1", Title: "Sync test"}
|
|
|
|
// nil client: should commit but skip sync.
|
|
err = CommitAndSync(context.Background(), nil, task, dir, "feat: sync test", 50)
|
|
require.NoError(t, err)
|
|
|
|
// Verify commit.
|
|
output, err := runGitCommandCtx(context.Background(), dir, "log", "--oneline", "-1")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, output, "feat: sync test")
|
|
}
|
|
|
|
func TestCommitAndSync_Good_WithClient(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Create a file to commit.
|
|
err := os.WriteFile(filepath.Join(dir, "synced.go"), []byte("package synced\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
var receivedUpdate TaskUpdate
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPatch {
|
|
_ = json.NewDecoder(r.Body).Decode(&receivedUpdate)
|
|
w.WriteHeader(http.StatusOK)
|
|
}
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
task := &Task{ID: "CS-2", Title: "Sync with client"}
|
|
|
|
err = CommitAndSync(context.Background(), client, task, dir, "feat: synced", 75)
|
|
require.NoError(t, err)
|
|
|
|
// Verify the update was sent.
|
|
assert.Equal(t, StatusInProgress, receivedUpdate.Status)
|
|
assert.Equal(t, 75, receivedUpdate.Progress)
|
|
assert.Contains(t, receivedUpdate.Notes, "feat: synced")
|
|
}
|
|
|
|
func TestCommitAndSync_Bad_CommitFails(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// No changes to commit.
|
|
task := &Task{ID: "CS-3", Title: "Will fail"}
|
|
|
|
err := CommitAndSync(context.Background(), nil, task, dir, "feat: no changes", 50)
|
|
assert.Error(t, err, "should fail when commit fails")
|
|
}
|
|
|
|
func TestCommitAndSync_Bad_SyncFails(t *testing.T) {
|
|
dir := initGitRepo(t)
|
|
|
|
// Create a file to commit.
|
|
err := os.WriteFile(filepath.Join(dir, "fail-sync.go"), []byte("package failsync\n"), 0644)
|
|
require.NoError(t, err)
|
|
|
|
// Server returns an error.
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
_ = json.NewEncoder(w).Encode(APIError{Message: "sync failed"})
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
task := &Task{ID: "CS-4", Title: "Sync fails"}
|
|
|
|
err = CommitAndSync(context.Background(), client, task, dir, "feat: sync-fail", 50)
|
|
assert.Error(t, err, "should report sync failure")
|
|
assert.Contains(t, err.Error(), "sync failed")
|
|
}
|
|
|
|
// --- SyncStatus with working client ---
|
|
|
|
func TestSyncStatus_Good(t *testing.T) {
|
|
var receivedUpdate TaskUpdate
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewDecoder(r.Body).Decode(&receivedUpdate)
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
task := &Task{ID: "sync-1", Title: "Sync test"}
|
|
|
|
err := SyncStatus(context.Background(), client, task, TaskUpdate{
|
|
Status: StatusCompleted,
|
|
Progress: 100,
|
|
Notes: "All done",
|
|
})
|
|
require.NoError(t, err)
|
|
assert.Equal(t, StatusCompleted, receivedUpdate.Status)
|
|
assert.Equal(t, 100, receivedUpdate.Progress)
|
|
}
|
|
|
|
// --- CreatePR with default title/body ---
|
|
|
|
func TestCreatePR_Good_DefaultTitleFromTask(t *testing.T) {
|
|
// CreatePR requires gh CLI which may not be available.
|
|
// Test the option building logic by checking that the title
|
|
// defaults to the task title.
|
|
task := &Task{
|
|
ID: "PR-1",
|
|
Title: "Add authentication",
|
|
Description: "OAuth2 login",
|
|
Priority: PriorityHigh,
|
|
}
|
|
|
|
opts := PROptions{}
|
|
|
|
// Verify the defaulting logic that would be used.
|
|
title := opts.Title
|
|
if title == "" {
|
|
title = task.Title
|
|
}
|
|
assert.Equal(t, "Add authentication", title)
|
|
|
|
body := opts.Body
|
|
if body == "" {
|
|
body = buildPRBody(task)
|
|
}
|
|
assert.Contains(t, body, "OAuth2 login")
|
|
}
|
|
|
|
func TestCreatePR_Good_CustomOptions(t *testing.T) {
|
|
opts := PROptions{
|
|
Title: "Custom title",
|
|
Body: "Custom body",
|
|
Draft: true,
|
|
Labels: []string{"enhancement", "v2"},
|
|
Base: "develop",
|
|
}
|
|
|
|
assert.Equal(t, "Custom title", opts.Title)
|
|
assert.True(t, opts.Draft)
|
|
assert.Equal(t, "develop", opts.Base)
|
|
assert.Len(t, opts.Labels, 2)
|
|
}
|
|
|
|
// --- Client checkResponse edge cases ---
|
|
|
|
func TestClient_CheckResponse_Good_GenericError(t *testing.T) {
|
|
// Test checkResponse with a non-JSON error body.
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusBadGateway)
|
|
_, _ = w.Write([]byte("plain text error"))
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetTask(context.Background(), "test-task")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Bad Gateway")
|
|
}
|
|
|
|
func TestClient_CheckResponse_Good_EmptyBody(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusForbidden)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
_, err := client.GetTask(context.Background(), "test-task")
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "Forbidden")
|
|
}
|
|
|
|
// --- Client Ping edge case ---
|
|
|
|
func TestClient_Ping_Bad_ServerReturns4xx(t *testing.T) {
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusUnauthorized)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
err := client.Ping(context.Background())
|
|
assert.Error(t, err)
|
|
assert.Contains(t, err.Error(), "status 401")
|
|
}
|
|
|
|
// --- Client ClaimTask without AgentID ---
|
|
|
|
func TestClient_ClaimTask_Good_NoAgentID(t *testing.T) {
|
|
claimedTask := Task{
|
|
ID: "task-no-agent",
|
|
Status: StatusInProgress,
|
|
}
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
// Verify no body sent when AgentID is empty.
|
|
assert.Equal(t, http.MethodPost, r.Method)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(claimedTask)
|
|
}))
|
|
defer server.Close()
|
|
|
|
client := NewClient(server.URL, "test-token")
|
|
// Explicitly leave AgentID empty.
|
|
client.AgentID = ""
|
|
|
|
task, err := client.ClaimTask(context.Background(), "task-no-agent")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "task-no-agent", task.ID)
|
|
}
|