diff --git a/server/src/lib/__tests__/upload-images.test.js b/server/src/lib/__tests__/upload-images.test.js new file mode 100644 index 0000000..d4ba6b9 --- /dev/null +++ b/server/src/lib/__tests__/upload-images.test.js @@ -0,0 +1,118 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import fs from 'node:fs' +import path from 'node:path' +import { persistMultipartImages } from '../upload-images.js' + +const UPLOADS_DIR = path.join(process.cwd(), 'uploads') +const TEST_PREFIX = 'upload-test-' + +describe('persistMultipartImages with eager mode', () => { + afterEach(async () => { + const files = await fs.promises.readdir(UPLOADS_DIR).catch(() => []) + for (const file of files) { + if (file.startsWith(TEST_PREFIX)) { + await fs.promises.unlink(path.join(UPLOADS_DIR, file)).catch(() => {}) + } + } + const cacheDir = path.join(UPLOADS_DIR, '.cache') + await fs.promises.rm(cacheDir, { recursive: true, force: true }).catch(() => {}) + }) + + it('returns WebP URLs when eager=true', async () => { + const sharp = (await import('sharp')).default + const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original.png`) + await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } } }) + .png() + .toFile(testImagePath) + + const filesBefore = await fs.promises.readdir(UPLOADS_DIR) + + const mockRequest = { + isMultipart: () => true, + parts: async function* () { + const buffer = await fs.promises.readFile(testImagePath) + yield { + file: true, + filename: 'test.png', + toBuffer: async () => buffer, + } + }, + } + + const urls = await persistMultipartImages(mockRequest, { + maxFiles: 1, + maxFileBytes: 20 * 1024 * 1024, + subdir: '', + eager: true, + }) + + expect(urls).toHaveLength(1) + expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.webp$/) + + // Verify the intermediate PNG file written by persistMultipartImages was deleted + const filesAfter = await fs.promises.readdir(UPLOADS_DIR) + const newPngFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f.endsWith('.png')) + expect(newPngFiles).toHaveLength(0) + }) + + it('returns original format URLs when eager=false', async () => { + const sharp = (await import('sharp')).default + const testImagePath = path.join(UPLOADS_DIR, `${TEST_PREFIX}original2.png`) + await sharp({ create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 255, b: 0 } } }) + .png() + .toFile(testImagePath) + + const mockRequest = { + isMultipart: () => true, + parts: async function* () { + const buffer = await fs.promises.readFile(testImagePath) + yield { + file: true, + filename: 'test.png', + toBuffer: async () => buffer, + } + }, + } + + const urls = await persistMultipartImages(mockRequest, { + maxFiles: 1, + maxFileBytes: 20 * 1024 * 1024, + subdir: '', + eager: false, + }) + + expect(urls).toHaveLength(1) + expect(urls[0]).toMatch(/\/uploads\/[a-f0-9-]+\.png$/) + }) + + it('cleans up original file on eager processing error', async () => { + const invalidBuffer = Buffer.from('not an image') + + const filesBefore = await fs.promises.readdir(UPLOADS_DIR) + + const mockRequest = { + isMultipart: () => true, + parts: async function* () { + yield { + file: true, + filename: 'test.png', + toBuffer: async () => invalidBuffer, + } + }, + } + + await expect( + persistMultipartImages(mockRequest, { + maxFiles: 1, + maxFileBytes: 20 * 1024 * 1024, + subdir: '', + eager: true, + }), + ).rejects.toThrow() + + // The intermediate file written by persistMultipartImages should be cleaned up + const filesAfter = await fs.promises.readdir(UPLOADS_DIR) + const newFiles = filesAfter.filter((f) => !filesBefore.includes(f) && f !== '.cache') + expect(newFiles).toHaveLength(0) + }) +}) diff --git a/server/src/lib/upload-images.js b/server/src/lib/upload-images.js index 7d9195a..4d8f965 100644 --- a/server/src/lib/upload-images.js +++ b/server/src/lib/upload-images.js @@ -14,7 +14,7 @@ export function uploadError(message, statusCode = 400) { return err } -export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '' }) { +export async function persistMultipartImages(request, { maxFiles = 10, maxFileBytes, subdir = '', eager = false }) { if (!request.isMultipart()) { throw uploadError('Ожидается multipart/form-data') } @@ -40,10 +40,25 @@ export async function persistMultipartImages(request, { maxFiles = 10, maxFileBy throw uploadError('Разрешены только файлы: png, jpg, jpeg, webp') } - const fileName = `${crypto.randomUUID()}${ext}` + const uuid = crypto.randomUUID() + const fileName = `${uuid}${ext}` const fullPath = path.join(targetDir, fileName) await fs.promises.writeFile(fullPath, await part.toBuffer()) - urls.push(subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}`) + + let finalUrl = subdir ? `/uploads/${subdir}/${fileName}` : `/uploads/${fileName}` + + if (eager) { + try { + const { generateAllSizes, convertOriginalToWebp } = await import('./image-resize.js') + await generateAllSizes(uuid, subdir, fullPath) + finalUrl = await convertOriginalToWebp(uuid, subdir) + } catch (error) { + await fs.promises.unlink(fullPath).catch(() => {}) + throw error + } + } + + urls.push(finalUrl) } if (urls.length === 0) {