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 fakeAttachmentRepo struct { softDel []runners.AttachmentRow orphan []runners.AttachmentRow softDelListErr error orphanListErr error deleteErr error deletedIDs []uuid.UUID softDelCalled bool orphanCalled bool } func (f *fakeAttachmentRepo) ListAttachmentsForSoftDeletedConversations(_ context.Context, _ time.Duration) ([]runners.AttachmentRow, error) { f.softDelCalled = true return f.softDel, f.softDelListErr } func (f *fakeAttachmentRepo) ListExpiredOrphans(_ context.Context, _ time.Duration) ([]runners.AttachmentRow, error) { f.orphanCalled = true return f.orphan, f.orphanListErr } func (f *fakeAttachmentRepo) DeleteAttachments(_ context.Context, ids []uuid.UUID) error { if f.deleteErr != nil { return f.deleteErr } f.deletedIDs = append(f.deletedIDs, ids...) return nil } type fakeStorage struct { deleted []string err error failPaths map[string]error } func (s *fakeStorage) Delete(_ context.Context, path string) error { if s.err != nil { return s.err } if e, ok := s.failPaths[path]; ok { return e } s.deleted = append(s.deleted, path) return nil } func TestAttachmentCleanup_DeletesBothCategories(t *testing.T) { t.Parallel() a, b, c := uuid.New(), uuid.New(), uuid.New() repo := &fakeAttachmentRepo{ softDel: []runners.AttachmentRow{{ID: a, StoragePath: "a"}, {ID: b, StoragePath: "b"}}, orphan: []runners.AttachmentRow{{ID: c, StoragePath: "c"}}, } st := &fakeStorage{} r := runners.NewAttachmentCleanup(repo, st, runners.AttachmentCleanupConfig{Retention: 30 * 24 * time.Hour}, discardLogger()) r.Run(context.Background()) assert.ElementsMatch(t, []string{"a", "b", "c"}, st.deleted) assert.ElementsMatch(t, []uuid.UUID{a, b, c}, repo.deletedIDs) } func TestAttachmentCleanup_StorageErrorDoesNotDeleteFromDB(t *testing.T) { t.Parallel() repo := &fakeAttachmentRepo{ softDel: []runners.AttachmentRow{ {ID: uuid.New(), StoragePath: "fail"}, {ID: uuid.New(), StoragePath: "ok"}, }, } st := &fakeStorage{err: errors.New("disk full")} r := runners.NewAttachmentCleanup(repo, st, runners.AttachmentCleanupConfig{Retention: 30 * 24 * time.Hour}, discardLogger()) r.Run(context.Background()) require.Empty(t, st.deleted, "storage error → no path recorded") require.Empty(t, repo.deletedIDs, "storage error → no DB delete batch") } func TestAttachmentCleanup_PartialStorageSuccessOnlyDeletesSuccessfulIDs(t *testing.T) { t.Parallel() succID := uuid.New() failID := uuid.New() repo := &fakeAttachmentRepo{ softDel: []runners.AttachmentRow{ {ID: succID, StoragePath: "ok"}, {ID: failID, StoragePath: "bad"}, }, } st := &fakeStorage{failPaths: map[string]error{"bad": errors.New("permission denied")}} r := runners.NewAttachmentCleanup(repo, st, runners.AttachmentCleanupConfig{Retention: 30 * 24 * time.Hour}, discardLogger()) r.Run(context.Background()) assert.ElementsMatch(t, []string{"ok"}, st.deleted, "only successful storage delete recorded") assert.ElementsMatch(t, []uuid.UUID{succID}, repo.deletedIDs, "DB delete batch only contains successfully-deleted-from-storage IDs") } func TestAttachmentCleanup_SoftDelListErrorDoesNotPreventOrphanPhase(t *testing.T) { t.Parallel() orphanID := uuid.New() repo := &fakeAttachmentRepo{ softDelListErr: errors.New("db blip"), orphan: []runners.AttachmentRow{{ID: orphanID, StoragePath: "p"}}, } st := &fakeStorage{} r := runners.NewAttachmentCleanup(repo, st, runners.AttachmentCleanupConfig{Retention: 30 * 24 * time.Hour}, discardLogger()) r.Run(context.Background()) assert.True(t, repo.softDelCalled, "soft-del list was attempted") assert.True(t, repo.orphanCalled, "orphan list still ran after soft-del list error") assert.ElementsMatch(t, []string{"p"}, st.deleted, "orphan storage delete proceeded") assert.ElementsMatch(t, []uuid.UUID{orphanID}, repo.deletedIDs) } func TestAttachmentCleanup_DBDeleteErrorIsLoggedNotPanicked(t *testing.T) { t.Parallel() id := uuid.New() repo := &fakeAttachmentRepo{ softDel: []runners.AttachmentRow{{ID: id, StoragePath: "p"}}, deleteErr: errors.New("constraint violation"), } st := &fakeStorage{} r := runners.NewAttachmentCleanup(repo, st, runners.AttachmentCleanupConfig{Retention: 30 * 24 * time.Hour}, discardLogger()) require.NotPanics(t, func() { r.Run(context.Background()) }, "DB delete error must not panic") assert.ElementsMatch(t, []string{"p"}, st.deleted, "storage delete proceeded before DB error") assert.Empty(t, repo.deletedIDs, "deleteErr means no IDs accumulated in fake") }