|
| 1 | +/** |
| 2 | + * @vitest-environment node |
| 3 | + */ |
| 4 | + |
| 5 | +import { beforeEach, describe, expect, it, vi } from 'vitest' |
| 6 | + |
| 7 | +const { |
| 8 | + mockBatchDeleteByWorkspaceAndTimestamp, |
| 9 | + mockDeleteFileMetadata, |
| 10 | + mockDeleteFiles, |
| 11 | + mockDeleteRowsById, |
| 12 | + mockIsUsingCloudStorage, |
| 13 | + mockLimit, |
| 14 | + mockOrderBy, |
| 15 | + mockPrepareChatCleanup, |
| 16 | + mockSelect, |
| 17 | + mockSelectRowsByIdChunks, |
| 18 | + mockTask, |
| 19 | + mockWhere, |
| 20 | +} = vi.hoisted(() => { |
| 21 | + const mockLimit = vi.fn(async () => [] as Array<{ key: string }>) |
| 22 | + const mockOrderBy = vi.fn(() => ({ limit: mockLimit })) |
| 23 | + const mockWhere = vi.fn(() => ({ orderBy: mockOrderBy, limit: mockLimit })) |
| 24 | + const mockFrom = vi.fn(() => ({ |
| 25 | + where: mockWhere, |
| 26 | + leftJoin: vi.fn(() => ({ where: mockWhere })), |
| 27 | + })) |
| 28 | + const mockSelect = vi.fn(() => ({ from: mockFrom })) |
| 29 | + |
| 30 | + return { |
| 31 | + mockBatchDeleteByWorkspaceAndTimestamp: vi.fn(async () => ({ deleted: 0, failed: 0 })), |
| 32 | + mockDeleteFileMetadata: vi.fn(async () => true), |
| 33 | + mockDeleteFiles: vi.fn(async () => ({ deleted: 0, failed: [] as Array<{ key: string }> })), |
| 34 | + mockDeleteRowsById: vi.fn(async () => ({ deleted: 0, failed: 0 })), |
| 35 | + mockIsUsingCloudStorage: vi.fn(() => true), |
| 36 | + mockLimit, |
| 37 | + mockOrderBy, |
| 38 | + mockPrepareChatCleanup: vi.fn(async () => ({ execute: vi.fn(async () => undefined) })), |
| 39 | + mockSelect, |
| 40 | + mockSelectRowsByIdChunks: vi.fn(async () => [] as unknown[]), |
| 41 | + mockTask: vi.fn((config: unknown) => config), |
| 42 | + mockWhere, |
| 43 | + } |
| 44 | +}) |
| 45 | + |
| 46 | +vi.mock('@sim/db', () => ({ db: { select: mockSelect } })) |
| 47 | + |
| 48 | +vi.mock('@sim/db/schema', () => { |
| 49 | + const table = (cols: string[]) => |
| 50 | + Object.fromEntries(cols.map((c) => [c, `col.${c}`])) as Record<string, string> |
| 51 | + const wsFileCols = ['id', 'key', 'context', 'workspaceId', 'deletedAt', 'uploadedAt'] |
| 52 | + const softCols = ['id', 'archivedAt', 'deletedAt', 'workspaceId'] |
| 53 | + return { |
| 54 | + a2aAgent: table(softCols), |
| 55 | + copilotChats: table(['id', 'workflowId']), |
| 56 | + document: table(['id', 'storageKey', 'knowledgeBaseId']), |
| 57 | + knowledgeBase: table(softCols), |
| 58 | + mcpServers: table(softCols), |
| 59 | + memory: table(softCols), |
| 60 | + userTableDefinitions: table(softCols), |
| 61 | + workflow: table(softCols), |
| 62 | + workflowFolder: table(softCols), |
| 63 | + workflowMcpServer: table(softCols), |
| 64 | + workspaceFile: table(wsFileCols), |
| 65 | + workspaceFiles: table(wsFileCols), |
| 66 | + } |
| 67 | +}) |
| 68 | + |
| 69 | +vi.mock('@sim/logger', () => ({ |
| 70 | + createLogger: vi.fn(() => ({ error: vi.fn(), info: vi.fn(), warn: vi.fn() })), |
| 71 | +})) |
| 72 | + |
| 73 | +vi.mock('@trigger.dev/sdk', () => ({ task: mockTask })) |
| 74 | + |
| 75 | +vi.mock('drizzle-orm', () => ({ |
| 76 | + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), |
| 77 | + asc: vi.fn((column: unknown) => ({ op: 'asc', column })), |
| 78 | + eq: vi.fn((...args: unknown[]) => ({ op: 'eq', args })), |
| 79 | + inArray: vi.fn((...args: unknown[]) => ({ op: 'inArray', args })), |
| 80 | + isNotNull: vi.fn((...args: unknown[]) => ({ op: 'isNotNull', args })), |
| 81 | + isNull: vi.fn((...args: unknown[]) => ({ op: 'isNull', args })), |
| 82 | + lt: vi.fn((...args: unknown[]) => ({ op: 'lt', args })), |
| 83 | + sql: vi.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({ strings, values })), |
| 84 | +})) |
| 85 | + |
| 86 | +vi.mock('@/lib/cleanup/batch-delete', () => ({ |
| 87 | + batchDeleteByWorkspaceAndTimestamp: mockBatchDeleteByWorkspaceAndTimestamp, |
| 88 | + chunkArray: (items: string[], size: number) => { |
| 89 | + const chunks: string[][] = [] |
| 90 | + for (let index = 0; index < items.length; index += size) { |
| 91 | + chunks.push(items.slice(index, index + size)) |
| 92 | + } |
| 93 | + return chunks |
| 94 | + }, |
| 95 | + deleteRowsById: mockDeleteRowsById, |
| 96 | + selectRowsByIdChunks: mockSelectRowsByIdChunks, |
| 97 | +})) |
| 98 | + |
| 99 | +vi.mock('@/lib/cleanup/chat-cleanup', () => ({ prepareChatCleanup: mockPrepareChatCleanup })) |
| 100 | + |
| 101 | +vi.mock('@/lib/uploads', () => ({ |
| 102 | + isUsingCloudStorage: mockIsUsingCloudStorage, |
| 103 | + StorageService: { deleteFiles: mockDeleteFiles }, |
| 104 | +})) |
| 105 | + |
| 106 | +vi.mock('@/lib/uploads/server/metadata', () => ({ deleteFileMetadata: mockDeleteFileMetadata })) |
| 107 | + |
| 108 | +import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes' |
| 109 | + |
| 110 | +const basePayload = { |
| 111 | + label: 'free/1', |
| 112 | + plan: 'free' as const, |
| 113 | + retentionHours: 720, |
| 114 | + workspaceIds: ['ws-1'], |
| 115 | +} |
| 116 | + |
| 117 | +describe('cleanup soft deletes — orphan KB binding sweep', () => { |
| 118 | + beforeEach(() => { |
| 119 | + vi.clearAllMocks() |
| 120 | + mockIsUsingCloudStorage.mockReturnValue(true) |
| 121 | + mockLimit.mockResolvedValue([]) |
| 122 | + }) |
| 123 | + |
| 124 | + it('soft-deletes abandoned KB bindings and removes their storage objects', async () => { |
| 125 | + mockLimit |
| 126 | + .mockResolvedValueOnce([{ key: 'kb/orphan-1' }, { key: 'kb/orphan-2' }]) |
| 127 | + .mockResolvedValueOnce([]) |
| 128 | + |
| 129 | + await runCleanupSoftDeletes(basePayload) |
| 130 | + |
| 131 | + expect(mockDeleteFiles).toHaveBeenCalledWith(['kb/orphan-1', 'kb/orphan-2'], 'knowledge-base') |
| 132 | + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-1') |
| 133 | + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-2') |
| 134 | + expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(2) |
| 135 | + }) |
| 136 | + |
| 137 | + it('still removes bindings but skips object deletion without cloud storage', async () => { |
| 138 | + mockIsUsingCloudStorage.mockReturnValue(false) |
| 139 | + mockLimit.mockResolvedValueOnce([{ key: 'kb/orphan-1' }]).mockResolvedValueOnce([]) |
| 140 | + |
| 141 | + await runCleanupSoftDeletes(basePayload) |
| 142 | + |
| 143 | + expect(mockDeleteFiles).not.toHaveBeenCalled() |
| 144 | + expect(mockDeleteFileMetadata).toHaveBeenCalledWith('kb/orphan-1') |
| 145 | + }) |
| 146 | + |
| 147 | + it('stops the batch loop when binding deletion makes no progress', async () => { |
| 148 | + mockLimit.mockResolvedValue([{ key: 'kb/stuck' }]) |
| 149 | + mockDeleteFileMetadata.mockRejectedValue(new Error('db down')) |
| 150 | + |
| 151 | + await runCleanupSoftDeletes(basePayload) |
| 152 | + |
| 153 | + // One batch attempted, then the no-progress guard breaks the loop. |
| 154 | + expect(mockDeleteFileMetadata).toHaveBeenCalledTimes(1) |
| 155 | + }) |
| 156 | + |
| 157 | + it('does not run the sweep when there are no workspaces', async () => { |
| 158 | + await runCleanupSoftDeletes({ ...basePayload, workspaceIds: [] }) |
| 159 | + |
| 160 | + expect(mockSelect).not.toHaveBeenCalled() |
| 161 | + expect(mockDeleteFiles).not.toHaveBeenCalled() |
| 162 | + expect(mockDeleteFileMetadata).not.toHaveBeenCalled() |
| 163 | + }) |
| 164 | +}) |
0 commit comments