/** @jest-environment node */ import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'fs/promises' import os from 'os' import path from 'path' import { NextRequest } from 'next/server' import { getServerSession } from 'next-auth' import { POST } from '@/app/api/materials/upload/complete/route' import { mergeUploadChunksAtomically, } from '@/lib/material-upload-merge' import { MaterialUploadStorageReservationManager } from '@/lib/material-upload-storage-reservation' import { cancelUploadSessionSeal } from '@/lib/material-upload-session-seal' import { deleteUploadSession, getOwnedUploadSession, } from '@/lib/upload-sessions' jest.mock('next-auth', () => ({ getServerSession: jest.fn(), })) jest.mock('@/lib/auth', () => ({ authOptions: {} })) jest.mock('@/lib/upload-sessions', () => ({ deleteUploadSession: jest.fn(), getOwnedUploadSession: jest.fn(), isSafeUploadId: jest.fn((uploadId: string) => /^upload-[A-Za-z0-9-]+$/.test(uploadId)), })) function completeRequest(uploadId = 'upload-test-1') { return new NextRequest('http://localhost/api/materials/upload/complete', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ uploadId, originalFileName: 'video.mp4' }), }) } describe('material upload complete safety', () => { let uploadRoot: string const uploadId = 'upload-test-1' const fileName = 'video.mp4' const fileBytes = Buffer.from('abcdefgh') beforeEach(async () => { jest.clearAllMocks() uploadRoot = await mkdtemp(path.join(os.tmpdir(), 'material-complete-')) process.env.UPLOAD_DIR = uploadRoot ;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } }) ;(getOwnedUploadSession as jest.Mock).mockResolvedValue({ fileName, fileSize: fileBytes.length, totalChunks: 2, chunkSize: 4, chunks: new Map(), createdAt: Date.now(), userId: 'user-1', }) ;(deleteUploadSession as jest.Mock).mockResolvedValue(undefined) const tempDir = path.join(uploadRoot, 'temp', uploadId) await mkdir(tempDir, { recursive: true }) await writeFile(path.join(tempDir, 'meta.json'), '{}') await writeFile(path.join(tempDir, 'chunk-0'), fileBytes.subarray(0, 4)) await writeFile(path.join(tempDir, 'chunk-1'), fileBytes.subarray(4)) }) afterEach(async () => { delete process.env.UPLOAD_DIR await rm(uploadRoot, { recursive: true, force: true }) }) test('首次成功响应丢失后,第二次 complete 幂等返回同一个完整文件', async () => { const firstResponse = await POST(completeRequest()) const firstBody = await firstResponse.json() expect(firstResponse.status).toBe(200) expect(await readFile(firstBody.existingFilePath)).toEqual(fileBytes) await expect(readFile(path.join(uploadRoot, 'temp', uploadId, 'chunk-0'))) .rejects.toMatchObject({ code: 'ENOENT' }) const secondResponse = await POST(completeRequest()) const secondBody = await secondResponse.json() expect(secondResponse.status).toBe(200) expect(secondBody.existingFilePath).toBe(firstBody.existingFilePath) expect(secondBody.fileSize).toBe(fileBytes.length) expect(await readFile(secondBody.existingFilePath)).toEqual(fileBytes) }) test('cancelled 终态让 complete 结构化 fail-closed', async () => { await cancelUploadSessionSeal(uploadId) const response = await POST(completeRequest()) const body = await response.json() expect(response.status).toBe(409) expect(body.code).toBe('UPLOAD_SESSION_CANCELLED') await expect(readFile(path.join(uploadRoot, 'temp', uploadId, 'chunk-0'))) .resolves.toEqual(fileBytes.subarray(0, 4)) expect(deleteUploadSession).not.toHaveBeenCalled() }) test('合并前磁盘不足时 fail-closed 并返回可识别的 507 错误', async () => { const reservations = new MaterialUploadStorageReservationManager( 100, async () => BigInt(1_000), ) await expect( reservations.acquire(uploadRoot, 901), ).rejects.toMatchObject({ status: 507, code: 'UPLOAD_STORAGE_LOW', }) }) test('合并预留会持续扣住额度,后续分片不能吃掉尚未写入的空间', async () => { const reservations = new MaterialUploadStorageReservationManager( 100, async () => BigInt(1_000), ) const releaseMerge = await reservations.acquire(uploadRoot, 700) expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(700) }) await expect(reservations.acquire(uploadRoot, 201)).rejects.toMatchObject({ status: 507, code: 'UPLOAD_STORAGE_LOW', }) releaseMerge() const releaseChunk = await reservations.acquire(uploadRoot, 201) expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(201) }) releaseChunk() expect(reservations.snapshot()).toEqual({ reservedBytes: BigInt(0) }) }) test('原子改名失败时删除本次 partial,不留下占盘半成品', async () => { const chunkPath = path.join(uploadRoot, 'chunk.bin') const occupiedOutputPath = path.join(uploadRoot, 'occupied-output') await writeFile(chunkPath, Buffer.from('safe')) await mkdir(occupiedOutputPath) await expect(mergeUploadChunksAtomically({ chunks: [{ path: chunkPath, expectedBytes: 4 }], expectedTotalBytes: 4, outputPath: occupiedOutputPath, })).rejects.toBeTruthy() expect( (await readdir(uploadRoot)).filter((name) => name.startsWith('occupied-output.partial-')), ).toEqual([]) }) })