/** @jest-environment node */ import { mkdir, mkdtemp, rm, stat, utimes, writeFile } from 'fs/promises' import os from 'os' import path from 'path' const mockRedisGet = jest.fn() const mockRedisDel = jest.fn() jest.mock('@/lib/upload-sessions-redis', () => ({ redis: { get: mockRedisGet, del: mockRedisDel, }, })) import { runCleanup, safelyCleanupMaterialUploadSession, } from '@/lib/temp-cleanup' import { readUploadSessionSeal } from '@/lib/material-upload-session-seal' describe('scheduled material upload cleanup Redis fail-closed', () => { let uploadRoot: string beforeEach(async () => { jest.clearAllMocks() uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-cleanup-redis-')) process.env.UPLOAD_DIR = uploadRoot process.env.USE_REDIS = 'true' mockRedisGet.mockRejectedValue(new Error('redis unavailable')) const uploadId = 'expired-redis-unknown' const tempDir = path.join(uploadRoot, 'temp', uploadId) const oldDate = new Date(Date.now() - 10 * 60 * 60 * 1000) await mkdir(tempDir, { recursive: true }) await writeFile(path.join(tempDir, 'meta.json'), JSON.stringify({ fileName: 'source.bin', fileSize: 4, totalChunks: 1, chunkSize: 4, userId: 'user-1', createdAt: oldDate.getTime(), })) await writeFile(path.join(tempDir, 'chunk-0'), 'data') await utimes(tempDir, oldDate, oldDate) }) afterEach(async () => { delete process.env.UPLOAD_DIR delete process.env.USE_REDIS await rm(uploadRoot, { recursive: true, force: true }) }) test('Redis read failure skips the expired directory without deleting state', async () => { const result = await runCleanup() const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown') expect(result).toMatchObject({ cleaned: 0, skippedUnavailable: 1, }) await expect(stat(tempDir)).resolves.toMatchObject({}) expect(mockRedisDel).not.toHaveBeenCalled() }) test('Redis delete failure leaves directory behind after durable cancellation tombstone', async () => { mockRedisGet.mockResolvedValue(JSON.stringify({ fileName: 'source.bin', fileSize: 4, totalChunks: 1, chunkSize: 4, chunks: [], createdAt: Date.now() - 10 * 60 * 60 * 1000, userId: 'user-1', })) mockRedisDel.mockRejectedValue(new Error('redis delete unavailable')) const result = await safelyCleanupMaterialUploadSession({ uploadId: 'expired-redis-unknown', intent: 'cancel', expectedOwnerId: 'user-1', }) const tempDir = path.join(uploadRoot, 'temp', 'expired-redis-unknown') expect(result).toMatchObject({ success: false, status: 'unavailable', code: 'UPLOAD_CLEANUP_REDIS_UNAVAILABLE', httpStatus: 503, }) await expect(stat(tempDir)).resolves.toMatchObject({}) await expect(readUploadSessionSeal('expired-redis-unknown')).resolves.toMatchObject({ state: 'cancelled', }) }) })