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/jobs/runners" ) type fakeJobsReapRepo struct { reapedIDs []uuid.UUID reapErr error capturedCutoff time.Time called bool } func (f *fakeJobsReapRepo) ReapStuckRunningJobs(_ context.Context, leasedBefore time.Time) ([]uuid.UUID, error) { f.called = true f.capturedCutoff = leasedBefore if f.reapErr != nil { return nil, f.reapErr } return f.reapedIDs, nil } type fakeJobsPurgeRepo struct { purged int64 purgeErr error capturedCutoff time.Time called bool } func (f *fakeJobsPurgeRepo) PurgeCompleted(_ context.Context, completedBefore time.Time) (int64, error) { f.called = true f.capturedCutoff = completedBefore if f.purgeErr != nil { return 0, f.purgeErr } return f.purged, nil } func TestJobReaper_RunCallsReapWithCorrectCutoff(t *testing.T) { t.Parallel() repo := &fakeJobsReapRepo{reapedIDs: []uuid.UUID{uuid.New(), uuid.New()}} timeout := 5 * time.Minute r := runners.NewJobReaper(repo, timeout, discardLogger()) before := time.Now() r.Run(context.Background()) after := time.Now() require.True(t, repo.called) expectedFloor := before.Add(-timeout).Add(-time.Second) // some slack expectedCeil := after.Add(-timeout).Add(time.Second) assert.True(t, !repo.capturedCutoff.Before(expectedFloor) && !repo.capturedCutoff.After(expectedCeil), "cutoff %s outside [%s, %s]", repo.capturedCutoff, expectedFloor, expectedCeil) } func TestJobReaper_RunErrorDoesNotPanic(t *testing.T) { t.Parallel() repo := &fakeJobsReapRepo{reapErr: errors.New("db down")} r := runners.NewJobReaper(repo, 5*time.Minute, discardLogger()) require.NotPanics(t, func() { r.Run(context.Background()) }) assert.True(t, repo.called) } func TestJobReaper_NoStuckJobsIsNoOp(t *testing.T) { t.Parallel() repo := &fakeJobsReapRepo{} // empty reapedIDs r := runners.NewJobReaper(repo, 5*time.Minute, discardLogger()) require.NotPanics(t, func() { r.Run(context.Background()) }) assert.True(t, repo.called) } func TestJobPurge_RunCallsPurgeWithCorrectCutoff(t *testing.T) { t.Parallel() repo := &fakeJobsPurgeRepo{purged: 17} retention := 7 * 24 * time.Hour p := runners.NewJobPurge(repo, retention, discardLogger()) before := time.Now() p.Run(context.Background()) after := time.Now() require.True(t, repo.called) expectedFloor := before.Add(-retention).Add(-time.Second) expectedCeil := after.Add(-retention).Add(time.Second) assert.True(t, !repo.capturedCutoff.Before(expectedFloor) && !repo.capturedCutoff.After(expectedCeil)) } func TestJobPurge_RunErrorDoesNotPanic(t *testing.T) { t.Parallel() repo := &fakeJobsPurgeRepo{purgeErr: errors.New("db error")} p := runners.NewJobPurge(repo, 7*24*time.Hour, discardLogger()) require.NotPanics(t, func() { p.Run(context.Background()) }) assert.True(t, repo.called) } func TestJobPurge_ZeroRowsIsNoOp(t *testing.T) { t.Parallel() repo := &fakeJobsPurgeRepo{purged: 0} p := runners.NewJobPurge(repo, 7*24*time.Hour, discardLogger()) require.NotPanics(t, func() { p.Run(context.Background()) }) assert.True(t, repo.called) } func TestJobReaper_NilLogFallsBackToDefault(t *testing.T) { t.Parallel() r := runners.NewJobReaper(&fakeJobsReapRepo{}, time.Minute, nil) require.NotNil(t, r) require.NotPanics(t, func() { r.Run(context.Background()) }) } func TestJobPurge_NilLogFallsBackToDefault(t *testing.T) { t.Parallel() p := runners.NewJobPurge(&fakeJobsPurgeRepo{}, time.Hour, nil) require.NotNil(t, p) require.NotPanics(t, func() { p.Run(context.Background()) }) }