Files
agentic-coding-workflow/internal/jobs/runners/worktree_prune_test.go
T
q792602257 b1ccce567e feat(jobs): emit job.enqueue + worktree.pruned audit rows
Deferred audit retrofits from Plan #5 self-review:
- WebhookNotifier now records a job.enqueue audit row after each
  successful repo.Enqueue (best-effort: failures are warn-logged, not
  propagated). Added optional audit.Recorder + *slog.Logger params to
  NewWebhookNotifier.
- WorktreePrune.executionPhase now records a worktree.pruned audit row
  after each successful FinishPruneSuccess. Added optional audit.Recorder
  param to NewWorktreePrune.
- app.go wires the existing auditRec into both constructors.
- integration_test.go: drop the _ = auditRec suppression and pass
  auditRec into the webhook notifier so the closed-loop test exercises
  the audit emit path.
2026-05-06 13:35:45 +08:00

198 lines
5.9 KiB
Go

package runners_test
import (
"context"
"errors"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
"github.com/yan1h/agent-coding-workflow/internal/jobs/runners"
)
// fakeWorktreeRepo is a fresh fake distinct from fakeWorkspaceFetchRepo.
type fakeWorktreeRepo struct {
warnList []runners.WorktreeRow
pruneList []runners.WorktreeRow
warnListErr error
pruneListErr error
markedWarning []uuid.UUID
markWarningErr error
tryStartResult runners.WorktreeRow
tryStartErr error
tryStartCalled map[uuid.UUID]bool
finalized map[uuid.UUID]string // "ok" | "rollback"
finishSuccessErr error
finishRollbackErr error
}
func (f *fakeWorktreeRepo) ListWorktreesNeedingPruneWarning(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
return f.warnList, f.warnListErr
}
func (f *fakeWorktreeRepo) ListWorktreesNeedingPrune(_ context.Context, _ time.Time) ([]runners.WorktreeRow, error) {
return f.pruneList, f.pruneListErr
}
func (f *fakeWorktreeRepo) MarkPruneWarning(_ context.Context, id uuid.UUID) error {
f.markedWarning = append(f.markedWarning, id)
return f.markWarningErr
}
func (f *fakeWorktreeRepo) TryStartPrune(_ context.Context, id uuid.UUID) (runners.WorktreeRow, error) {
if f.tryStartCalled == nil {
f.tryStartCalled = map[uuid.UUID]bool{}
}
f.tryStartCalled[id] = true
if f.tryStartErr != nil {
return runners.WorktreeRow{}, f.tryStartErr
}
return f.tryStartResult, nil
}
func (f *fakeWorktreeRepo) FinishPruneSuccess(_ context.Context, id uuid.UUID) error {
if f.finalized == nil {
f.finalized = map[uuid.UUID]string{}
}
f.finalized[id] = "ok"
return f.finishSuccessErr
}
func (f *fakeWorktreeRepo) FinishPruneRollback(_ context.Context, id uuid.UUID) error {
if f.finalized == nil {
f.finalized = map[uuid.UUID]string{}
}
f.finalized[id] = "rollback"
return f.finishRollbackErr
}
// fakePruner records WorktreeRemove calls.
type fakePruner struct {
err error
calls int
}
func (f *fakePruner) WorktreeRemove(_ context.Context, _, _ string) error {
f.calls++
return f.err
}
func TestWorktreePrune_WarningPhase(t *testing.T) {
t.Parallel()
id := uuid.New()
ownerID := uuid.New()
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-x", MainPath: "/main", Path: "/main/.worktrees/feat-x"}
repo := &fakeWorktreeRepo{warnList: []runners.WorktreeRow{row}}
disp := &recordingDispatcher{}
gitr := &fakePruner{}
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
r.Run(context.Background())
require.Len(t, repo.markedWarning, 1)
assert.Equal(t, id, repo.markedWarning[0])
require.Len(t, disp.msgs, 1)
msg := disp.msgs[0]
assert.Equal(t, ownerID, msg.UserID)
assert.Equal(t, "worktree.prune_pending", msg.Topic)
assert.Equal(t, notify.SeverityWarning, msg.Severity)
// execution phase: pruneList is empty, so no prune calls.
assert.Zero(t, gitr.calls)
}
func TestWorktreePrune_ExecutionPhaseSuccess(t *testing.T) {
t.Parallel()
id := uuid.New()
ownerID := uuid.New()
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-y", MainPath: "/main", Path: "/main/.worktrees/feat-y"}
repo := &fakeWorktreeRepo{
pruneList: []runners.WorktreeRow{row},
tryStartResult: row,
}
gitr := &fakePruner{}
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
r.Run(context.Background())
assert.True(t, repo.tryStartCalled[id])
assert.Equal(t, 1, gitr.calls)
require.NotNil(t, repo.finalized)
assert.Equal(t, "ok", repo.finalized[id])
}
func TestWorktreePrune_ExecutionFailureRollsBack(t *testing.T) {
t.Parallel()
id := uuid.New()
ownerID := uuid.New()
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-z", MainPath: "/main", Path: "/main/.worktrees/feat-z"}
repo := &fakeWorktreeRepo{
pruneList: []runners.WorktreeRow{row},
tryStartResult: row,
}
gitr := &fakePruner{err: errors.New("git worktree remove failed")}
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
r.Run(context.Background())
assert.True(t, repo.tryStartCalled[id])
assert.Equal(t, 1, gitr.calls)
require.NotNil(t, repo.finalized)
assert.Equal(t, "rollback", repo.finalized[id])
}
func TestWorktreePrune_NoRowsIsNoOp(t *testing.T) {
t.Parallel()
repo := &fakeWorktreeRepo{} // both lists nil
gitr := &fakePruner{}
disp := &recordingDispatcher{}
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
r := runners.NewWorktreePrune(repo, gitr, disp, cfg, discardLogger(), nil)
r.Run(context.Background())
assert.Empty(t, repo.markedWarning)
assert.Empty(t, repo.tryStartCalled)
assert.Empty(t, repo.finalized)
assert.Zero(t, gitr.calls)
assert.Empty(t, disp.msgs)
}
func TestWorktreePrune_CASLostSkipsSilently(t *testing.T) {
t.Parallel()
id := uuid.New()
ownerID := uuid.New()
row := runners.WorktreeRow{ID: id, OwnerID: ownerID, Branch: "feat-cas", MainPath: "/main", Path: "/main/.worktrees/feat-cas"}
repo := &fakeWorktreeRepo{
pruneList: []runners.WorktreeRow{row},
tryStartErr: errors.New("worktree no longer idle"),
}
gitr := &fakePruner{}
cfg := runners.WorktreePruneConfig{IdleThreshold: 7 * 24 * time.Hour, WarningLead: 3 * 24 * time.Hour}
r := runners.NewWorktreePrune(repo, gitr, &recordingDispatcher{}, cfg, discardLogger(), nil)
r.Run(context.Background())
// TryStartPrune was called but failed.
assert.True(t, repo.tryStartCalled[id])
// No git call.
assert.Zero(t, gitr.calls)
// No finalize for that ID.
assert.Empty(t, repo.finalized)
}