import assert from 'node:assert/strict' import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Readable } from 'node:stream' import test from 'node:test' import { decodeMultipartFilename, saveMultipartFilesToDisk } from '../server/utils/multipart-file-upload.js' const boundary = '----sori-stream-test' /** * 테스트용 multipart 요청 본문을 생성한다. * @param {Array<{ name: string, type: string, content: string|Buffer }>} files - 파일 목록 * @returns {Buffer} multipart 본문 */ const createMultipartBody = (files) => { const chunks = [] for (const file of files) { chunks.push(Buffer.from( `--${boundary}\r\n` + 'Content-Disposition: form-data; name="files"; filename="' )) chunks.push(Buffer.isBuffer(file.name) ? file.name : Buffer.from(String(file.name), 'utf8')) chunks.push(Buffer.from( `"\r\nContent-Type: ${file.type}\r\n\r\n` )) chunks.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(String(file.content), 'utf8')) chunks.push(Buffer.from('\r\n')) } chunks.push(Buffer.from(`--${boundary}--\r\n`)) return Buffer.concat(chunks) } /** * 테스트용 H3 이벤트 요청 객체를 생성한다. * @param {Buffer} body - 요청 본문 * @returns {{ node: { req: Readable & { headers: Record } } }} 요청 이벤트 대체 객체 */ const createMultipartEvent = (body) => { const request = Readable.from([body]) request.headers = { 'content-type': `multipart/form-data; boundary=${boundary}`, 'content-length': String(body.length) } return { node: { req: request } } } /** * 여러 파일이 메모리 Buffer 변환 없이 디스크에 저장되는지 검증한다. * @returns {Promise} */ const testMultipleFileStreaming = async () => { const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-test-')) try { const body = createMultipartBody([ { name: 'one.txt', type: 'text/plain', content: '첫 번째' }, { name: 'two.txt', type: 'text/plain', content: '두 번째 파일' } ]) const files = await saveMultipartFilesToDisk(createMultipartEvent(body), { fieldName: 'files', maxFiles: 2, maxTotalBytes: 1024, createFileTarget: async (_file, index) => ({ filePath: join(directory, `${index}.txt`), maxBytes: 128 }) }) const contents = await Promise.all(files.map((file) => readFile(file.filePath, 'utf8'))) assert.deepEqual(contents, ['첫 번째', '두 번째 파일']) } finally { await rm(directory, { recursive: true, force: true }) } } /** * 파일별 스트리밍 크기 제한을 검증한다. * @returns {Promise} */ const testFileSizeLimit = async () => { const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-limit-test-')) try { const body = createMultipartBody([ { name: 'large.txt', type: 'text/plain', content: '1234567890' } ]) await assert.rejects( saveMultipartFilesToDisk(createMultipartEvent(body), { fieldName: 'files', maxFiles: 1, maxTotalBytes: 1024, createFileTarget: async () => ({ filePath: join(directory, 'large.txt'), maxBytes: 5 }) }), (error) => error?.code === 'MULTIPART_FILE_TOO_LARGE' ) } finally { await rm(directory, { recursive: true, force: true }) } } test('다중 multipart 파일을 디스크로 스트리밍한다', testMultipleFileStreaming) test('multipart 파일별 크기 제한을 적용한다', testFileSizeLimit) test('busboy Latin-1 오해로 깨진 한글 파일명을 복구한다', () => { const nfdName = '스크린샷 2026-06-25 오후 5.13.27'.normalize('NFD') const latin1Mojibake = Buffer.from(nfdName, 'utf8').toString('latin1') assert.equal( decodeMultipartFilename(latin1Mojibake), '스크린샷 2026-06-25 오후 5.13.27' ) assert.equal( decodeMultipartFilename('스크린샷-2026-06-25-오후-5.13.27.png'), '스크린샷-2026-06-25-오후-5.13.27.png' ) assert.equal(decodeMultipartFilename('café.png'), 'café.png') }) test('UTF-8 한글 파일명 multipart를 올바르게 읽는다', async () => { const directory = await mkdtemp(join(tmpdir(), 'sori-multipart-korean-')) try { const koreanName = Buffer.from('스크린샷 2026-06-25 오후 5.13.27.png', 'utf8') const body = createMultipartBody([ { name: koreanName, type: 'image/png', content: 'png-bytes' } ]) const files = await saveMultipartFilesToDisk(createMultipartEvent(body), { fieldName: 'files', maxFiles: 1, maxTotalBytes: 1024, createFileTarget: async (file) => ({ filePath: join(directory, 'upload.bin'), maxBytes: 128, metadata: { filename: file.filename } }) }) assert.equal(files[0].filename, '스크린샷 2026-06-25 오후 5.13.27.png') assert.equal(files[0].metadata.filename, '스크린샷 2026-06-25 오후 5.13.27.png') } finally { await rm(directory, { recursive: true, force: true }) } })