From 144bb81721814c19eb4957d4c8fcc845c73e2aa4 Mon Sep 17 00:00:00 2001 From: Josef Bredreck <13408112+JosefBredereck@users.noreply.github.com> Date: Tue, 1 Aug 2023 15:20:50 +0200 Subject: [PATCH] feat: add support for sharp resize options (#2844) * feat(ImageResize): add support for resize options * fix(ImageUpload): reuse name for accidental duplicate * fix(ImageResize): e2e tests for added media size * chore: simplify fileExists method * fix: typo * feat(ImageResize): update name to be more transparent * fix: use fileExists in file removal * improve names, comments and clarity of needsResize function * fix: jsDoc params * fix: incorrect needsResize condition and add failing test case * chore: improve comment * fix: merge conflict error --------- Co-authored-by: Alessio Gravili <70709113+AlessioGr@users.noreply.github.com> --- src/uploads/deleteAssociatedFiles.ts | 37 ++-- src/uploads/fileExists.ts | 5 +- src/uploads/generateFileData.ts | 4 +- src/uploads/imageResizer.ts | 285 ++++++++++++++++++--------- src/uploads/types.ts | 12 +- test/helpers/removeFiles.ts | 6 +- test/uploads/config.ts | 113 ++++++++++- test/uploads/e2e.spec.ts | 9 +- test/uploads/int.spec.ts | 208 ++++++++++++++----- 9 files changed, 509 insertions(+), 170 deletions(-) diff --git a/src/uploads/deleteAssociatedFiles.ts b/src/uploads/deleteAssociatedFiles.ts index b15222b167..5627c9f4ce 100644 --- a/src/uploads/deleteAssociatedFiles.ts +++ b/src/uploads/deleteAssociatedFiles.ts @@ -1,11 +1,11 @@ import path from 'path'; import fs from 'fs'; import type { TFunction } from 'i18next'; -import fileExists from './fileExists'; import { ErrorDeletingFile } from '../errors'; import type { FileData, FileToSave } from './types'; import type { SanitizedConfig } from '../config/types'; import type { SanitizedCollectionConfig } from '../collections/config/types'; +import fileExists from './fileExists'; type Args = { config: SanitizedConfig @@ -31,26 +31,31 @@ export const deleteAssociatedFiles: (args: Args) => Promise = async ({ const fileToDelete = `${staticPath}/${doc.filename}`; - if (await fileExists(fileToDelete)) { - fs.unlink(fileToDelete, (err) => { - if (err) { - throw new ErrorDeletingFile(t); - } - }); + try { + if (await fileExists(fileToDelete)) { + fs.unlinkSync(fileToDelete); + } + } catch (err) { + throw new ErrorDeletingFile(t); } if (doc.sizes) { - Object.values(doc.sizes) - .forEach(async (size: FileData) => { - const sizeToDelete = `${staticPath}/${size.filename}`; + const sizes: FileData[] = Object.values(doc.sizes); + // Since forEach will not wait until unlink is finished it could + // happen that two operations will try to delete the same file. + // To avoid this it is recommended to use "sync" instead + // eslint-disable-next-line no-restricted-syntax + for (const size of sizes) { + const sizeToDelete = `${staticPath}/${size.filename}`; + try { + // eslint-disable-next-line no-await-in-loop if (await fileExists(sizeToDelete)) { - fs.unlink(sizeToDelete, (err) => { - if (err) { - throw new ErrorDeletingFile(t); - } - }); + fs.unlinkSync(sizeToDelete); } - }); + } catch (err) { + throw new ErrorDeletingFile(t); + } + } } } }; diff --git a/src/uploads/fileExists.ts b/src/uploads/fileExists.ts index 08077524c9..44ec18bf5e 100644 --- a/src/uploads/fileExists.ts +++ b/src/uploads/fileExists.ts @@ -1,11 +1,8 @@ import fs from 'fs'; -import { promisify } from 'util'; - -const stat = promisify(fs.stat); const fileExists = async (filename: string): Promise => { try { - await stat(filename); + await fs.promises.stat(filename); return true; } catch (err) { diff --git a/src/uploads/generateFileData.ts b/src/uploads/generateFileData.ts index 2138fd9d1b..c2fe72ecd8 100644 --- a/src/uploads/generateFileData.ts +++ b/src/uploads/generateFileData.ts @@ -9,7 +9,7 @@ import { FileUploadError, MissingFile } from '../errors'; import { PayloadRequest } from '../express/types'; import getImageSize from './getImageSize'; import getSafeFileName from './getSafeFilename'; -import resizeAndSave from './imageResizer'; +import resizeAndTransformImageSizes from './imageResizer'; import { FileData, FileToSave, ProbedImageSize } from './types'; import canResizeImage from './canResizeImage'; import isImage from './isImage'; @@ -155,7 +155,7 @@ export const generateFileData = async ({ if (Array.isArray(imageSizes) && fileSupportsResize) { req.payloadUploadSizes = {}; - const { sizeData, sizesToSave } = await resizeAndSave({ + const { sizeData, sizesToSave } = await resizeAndTransformImageSizes({ req, file, dimensions, diff --git a/src/uploads/imageResizer.ts b/src/uploads/imageResizer.ts index 7cc9445559..7da8f0a313 100644 --- a/src/uploads/imageResizer.ts +++ b/src/uploads/imageResizer.ts @@ -6,128 +6,229 @@ import sharp from 'sharp'; import { SanitizedCollectionConfig } from '../collections/config/types'; import { PayloadRequest } from '../express/types'; import fileExists from './fileExists'; -import { FileSizes, FileToSave, ImageSize } from './types'; +import { FileSize, FileSizes, FileToSave, ImageSize, ProbedImageSize } from './types'; -type Dimensions = { width?: number, height?: number } +type ResizeArgs = { + req: PayloadRequest; + file: UploadedFile; + dimensions: ProbedImageSize; + staticPath: string; + config: SanitizedCollectionConfig; + savedFilename: string; + mimeType: string; +}; -type Args = { - req: PayloadRequest - file: UploadedFile - dimensions: Dimensions - staticPath: string - config: SanitizedCollectionConfig - savedFilename: string - mimeType: string -} +/** Result from resizing and transforming the requested image sizes */ +type ImageSizesResult = { + sizeData: FileSizes; + sizesToSave: FileToSave[]; +}; -type OutputImage = { - name: string, - extension: string, - width: number, - height: number -} +type SanitizedImageData = { + name: string; + ext: string; +}; -type Result = Promise<{ - sizeData: FileSizes - sizesToSave: FileToSave[] -}> - -function getOutputImage(sourceImage: string, size: ImageSize): OutputImage { +/** + * Sanitize the image name and extract the extension from the source image + * + * @param sourceImage - the source image + * @returns the sanitized name and extension + */ +const getSanitizedImageData = (sourceImage: string): SanitizedImageData => { const extension = sourceImage.split('.').pop(); const name = sanitize(sourceImage.substring(0, sourceImage.lastIndexOf('.')) || sourceImage); + return { name, ext: extension! }; +}; - return { - name, - extension, - width: size.width, - height: size.height, - }; -} +/** + * Create a new image name based on the output image name, the dimensions and + * the extension. + * + * Ignore the fact that duplicate names could happen if the there is one + * size with `width AND height` and one with only `height OR width`. Because + * space is expensive, we will reuse the same image for both sizes. + * + * @param outputImageName - the sanitized image name + * @param bufferInfo - the buffer info + * @param extension - the extension to use + * @returns the new image name that is not taken + */ +const createImageName = ( + outputImageName: string, + { width, height }: sharp.OutputInfo, + extension: string, +) => `${outputImageName}-${width}x${height}.${extension}`; -export default async function resizeAndSave({ +/** + * Create the result object for the image resize operation based on the + * provided parameters. If the name is not provided, an empty result object + * is returned. + * + * @param name - the name of the image + * @param filename - the filename of the image + * @param width - the width of the image + * @param height - the height of the image + * @param filesize - the filesize of the image + * @param mimeType - the mime type of the image + * @param sizesToSave - the sizes to save + * @returns the result object + */ +const createResult = ( + name: string, + filename: FileSize['filename'] = null, + width: FileSize['width'] = null, + height: FileSize['height'] = null, + filesize: FileSize['filesize'] = null, + mimeType: FileSize['mimeType'] = null, + sizesToSave: FileToSave[] = [], +): ImageSizesResult => ({ + sizesToSave, + sizeData: { + [name]: { + width, + height, + filename, + filesize, + mimeType, + }, + }, +}); + +/** + * Check if the image needs to be resized according to the requested dimensions + * and the original image size. If the resize options withoutEnlargement or withoutReduction are provided, + * the image will be resized regardless of the requested dimensions, given that the + * width or height to be resized is provided. + * + * @param resizeConfig - object containing the requested dimensions and resize options + * @param original - the original image size + * @returns true if the image needs to be resized, false otherwise + */ +const needsResize = ( + { width: desiredWidth, height: desiredHeigth, withoutEnlargement, withoutReduction }: ImageSize, + original: ProbedImageSize, +): boolean => { + // allow enlargement or prevent reduction (our default is to prevent + // enlargement and allow reduction) + if (withoutEnlargement !== undefined || withoutReduction !== undefined) { + return true; // needs resize + } + + const isWidthOrHeightNotDefined = !desiredHeigth || !desiredWidth; + if (isWidthOrHeightNotDefined) { + // If with and height are not defined, it means there is a format conversion + // and the image needs to be "resized" (transformed). + return true; // needs resize + } + + const hasInsufficientWidth = original.width < desiredWidth; + const hasInsufficientHeight = original.height < desiredHeigth; + if (hasInsufficientWidth && hasInsufficientHeight) { + // doesn't need resize - prevent enlargement. This should only happen if both width and height are insufficient. + // if only one dimension is insufficient and the other is sufficient, resizing needs to happen, as the image + // should be resized to the sufficient dimension. + return false; + } + + return true; // needs resize +}; + +/** + * For the provided image sizes, handle the resizing and the transforms + * (format, trim, etc.) of each requested image size and return the result object. + * This only handles the image sizes. The transforms of the original image + * are handled in {@link ./generateFileData.ts}. + * + * The image will be resized according to the provided + * resize config. If no image sizes are requested, the resolved data will be empty. + * For every image that dos not need to be resized, an result object with `null` + * parameters will be returned. + * + * @param resizeConfig - the resize config + * @returns the result of the resize operation(s) + */ +export default async function resizeAndTransformImageSizes({ req, file, dimensions, staticPath, config, savedFilename, -}: Args): Promise { + mimeType, +}: ResizeArgs): Promise { const { imageSizes } = config.upload; - const sizesToSave: FileToSave[] = []; - const sizeData = {}; + + // Noting to resize here so return as early as possible + if (!imageSizes) return { sizeData: {}, sizesToSave: [] }; const sharpBase = sharp(file.tempFilePath || file.data).rotate(); // pass rotate() to auto-rotate based on EXIF data. https://github.com/payloadcms/payload/pull/3081 - const promises = imageSizes - .map(async (desiredSize) => { - if (!needsResize(desiredSize, dimensions)) { - sizeData[desiredSize.name] = { - url: null, - width: null, - height: null, - filename: null, - filesize: null, - mimeType: null, - }; - return; - } - let resized = sharpBase.clone().resize(desiredSize); - - if (desiredSize.formatOptions) { - resized = resized.toFormat(desiredSize.formatOptions.format, desiredSize.formatOptions.options); + const results: ImageSizesResult[] = await Promise.all( + imageSizes.map(async (imageResizeConfig): Promise => { + // This checks if a resize should happen. If not, the resized image will be + // skipped COMPLETELY and thus will not be included in the resulting images. + // All further format/trim options will thus be skipped as well. + if (!needsResize(imageResizeConfig, dimensions)) { + return createResult(imageResizeConfig.name); } - if (desiredSize.trimOptions) { - resized = resized.trim(desiredSize.trimOptions); + let resized = sharpBase.clone().resize(imageResizeConfig); + + if (imageResizeConfig.formatOptions) { + resized = resized.toFormat( + imageResizeConfig.formatOptions.format, + imageResizeConfig.formatOptions.options, + ); } - const bufferObject = await resized.toBuffer({ + if (imageResizeConfig.trimOptions) { + resized = resized.trim(imageResizeConfig.trimOptions); + } + + const { data: bufferData, info: bufferInfo } = await resized.toBuffer({ resolveWithObject: true, }); - req.payloadUploadSizes[desiredSize.name] = bufferObject.data; + const sanitizedImage = getSanitizedImageData(savedFilename); + + if (req.payloadUploadSizes) { + req.payloadUploadSizes[imageResizeConfig.name] = bufferData; + } + + const mimeInfo = await fromBuffer(bufferData); + + const imageNameWithDimensions = createImageName( + sanitizedImage.name, + bufferInfo, + mimeInfo?.ext || sanitizedImage.ext, + ); - const mimeType = (await fromBuffer(bufferObject.data)); - const outputImage = getOutputImage(savedFilename, desiredSize); - const imageNameWithDimensions = createImageName(outputImage, bufferObject, mimeType.ext); const imagePath = `${staticPath}/${imageNameWithDimensions}`; - const fileAlreadyExists = await fileExists(imagePath); - if (fileAlreadyExists) { + if (await fileExists(imagePath)) { fs.unlinkSync(imagePath); } - sizesToSave.push({ - path: imagePath, - buffer: bufferObject.data, - }); + const { width, height, size } = bufferInfo; + return createResult( + imageResizeConfig.name, + imageNameWithDimensions, + width, + height, + size, + mimeInfo?.mime || mimeType, + [{ path: imagePath, buffer: bufferData }], + ); + }), + ); - sizeData[desiredSize.name] = { - width: bufferObject.info.width, - height: bufferObject.info.height, - filename: imageNameWithDimensions, - filesize: bufferObject.info.size, - mimeType: mimeType.mime, - }; - }); - - await Promise.all(promises); - - return { - sizeData, - sizesToSave, - }; -} -function createImageName( - outputImage: OutputImage, - bufferObject: { data: Buffer; info: sharp.OutputInfo }, - extension: string, -): string { - return `${outputImage.name}-${bufferObject.info.width}x${bufferObject.info.height}.${extension}`; -} - -function needsResize(desiredSize: ImageSize, dimensions: Dimensions): boolean { - return (typeof desiredSize.width === 'number' && desiredSize.width <= dimensions.width) - || (typeof desiredSize.height === 'number' && desiredSize.height <= dimensions.height) - || (!desiredSize.height && !desiredSize.width); + return results.reduce( + (acc, result) => { + Object.assign(acc.sizeData, result.sizeData); + acc.sizesToSave.push(...result.sizesToSave); + return acc; + }, + { sizeData: {}, sizesToSave: [] }, + ); } diff --git a/src/uploads/types.ts b/src/uploads/types.ts index 16998142ac..b4aa996375 100644 --- a/src/uploads/types.ts +++ b/src/uploads/types.ts @@ -4,12 +4,12 @@ import serveStatic from 'serve-static'; import { Sharp, ResizeOptions } from 'sharp'; export type FileSize = { - filename: string; - filesize: number; - mimeType: string; - width: number; - height: number; -} + filename: string | null; + filesize: number | null; + mimeType: string | null; + width: number | null; + height: number | null; +}; export type FileSizes = { [size: string]: FileSize diff --git a/test/helpers/removeFiles.ts b/test/helpers/removeFiles.ts index 6ac9b1f523..8e4fc94adc 100644 --- a/test/helpers/removeFiles.ts +++ b/test/helpers/removeFiles.ts @@ -1,7 +1,11 @@ import fs from 'fs'; const removeFiles = (dir) => { - if (fs.existsSync(dir)) fs.readdirSync(dir).forEach((f) => fs.rmSync(`${dir}/${f}`)); + if (!fs.existsSync(dir)) return; + + fs.readdirSync(dir).forEach((f) => { + return fs.rmSync(`${dir}/${f}`, { recursive: true }); + }); }; export default removeFiles; diff --git a/test/uploads/config.ts b/test/uploads/config.ts index 7a22c19865..2b68bb333e 100644 --- a/test/uploads/config.ts +++ b/test/uploads/config.ts @@ -13,6 +13,10 @@ export const relationSlug = 'relation'; export const audioSlug = 'audio'; +export const enlargeSlug = 'enlarge'; + +export const reduceSlug = 'reduce'; + export const adminThumbnailSlug = 'admin-thumbnail'; const mockModulePath = path.resolve(__dirname, './mocks/mockFSModule.js'); @@ -92,7 +96,14 @@ export default buildConfigWithDefaults({ upload: { staticURL: '/media', staticDir: './media', - mimeTypes: ['image/png', 'image/jpg', 'image/jpeg', 'image/gif', 'image/svg+xml', 'audio/mpeg'], + mimeTypes: [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'audio/mpeg', + ], resizeOptions: { width: 1280, height: 720, @@ -128,6 +139,11 @@ export default buildConfigWithDefaults({ height: undefined, formatOptions: { format: 'jpg', options: { quality: 90 } }, }, + { + name: 'accidentalSameSize', + width: 320, + height: 80, + }, { name: 'tablet', width: 640, @@ -149,6 +165,99 @@ export default buildConfigWithDefaults({ }, fields: [], }, + { + slug: enlargeSlug, + upload: { + staticURL: '/enlarge', + staticDir: './media/enlarge', + mimeTypes: [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'audio/mpeg', + ], + imageSizes: [ + { + name: 'accidentalSameSize', + width: 320, + height: 80, + withoutEnlargement: false, + }, + { + name: 'sameSizeWithNewFormat', + width: 320, + height: 80, + formatOptions: { format: 'jpg', options: { quality: 90 } }, + withoutEnlargement: false, + }, + { + name: 'resizedLarger', + width: 640, + height: 480, + withoutEnlargement: false, + }, + { + name: 'resizedSmaller', + width: 180, + height: 50, + }, + { + name: 'widthLowerHeightLarger', + width: 300, + height: 300, + fit: 'contain', + }, + ], + }, + fields: [], + }, + { + slug: reduceSlug, + upload: { + staticURL: '/reduce', + staticDir: './media/reduce', + mimeTypes: [ + 'image/png', + 'image/jpg', + 'image/jpeg', + 'image/gif', + 'image/svg+xml', + 'audio/mpeg', + ], + imageSizes: [ + { + name: 'accidentalSameSize', + width: 320, + height: 80, + withoutEnlargement: false, + }, + { + name: 'sameSizeWithNewFormat', + width: 320, + height: 80, + formatOptions: { format: 'jpg', options: { quality: 90 } }, + withoutReduction: true, + fit: 'contain', + }, + { + name: 'resizedLarger', + width: 640, + height: 480, + }, + { + name: 'resizedSmaller', + width: 180, + height: 50, + // Why fit `contain` should also be set to https://github.com/lovell/sharp/issues/3595 + withoutReduction: true, + fit: 'contain', + }, + ], + }, + fields: [], + }, { slug: 'media-trim', upload: { @@ -205,7 +314,7 @@ export default buildConfigWithDefaults({ ], onInit: async (payload) => { const uploadsDir = path.resolve(__dirname, './media'); - removeFiles(uploadsDir); + removeFiles(path.normalize(uploadsDir)); await payload.create({ collection: 'users', diff --git a/test/uploads/e2e.spec.ts b/test/uploads/e2e.spec.ts index 0bf46863b1..26a9c7572e 100644 --- a/test/uploads/e2e.spec.ts +++ b/test/uploads/e2e.spec.ts @@ -107,13 +107,16 @@ describe('uploads', () => { await expect(maintainedImageSizeWithNewFormatMeta).toContainText('image/jpeg'); await expect(maintainedImageSizeWithNewFormatMeta).toContainText('1600x1600'); - const tabletMeta = page.locator('.file-details__sizes .file-meta').nth(4); + const sameSizeMeta = page.locator('.file-details__sizes .file-meta').nth(4); + await expect(sameSizeMeta).toContainText('320x80'); + + const tabletMeta = page.locator('.file-details__sizes .file-meta').nth(5); await expect(tabletMeta).toContainText('640x480'); - const mobileMeta = page.locator('.file-details__sizes .file-meta').nth(5); + const mobileMeta = page.locator('.file-details__sizes .file-meta').nth(6); await expect(mobileMeta).toContainText('320x240'); - const iconMeta = page.locator('.file-details__sizes .file-meta').nth(6); + const iconMeta = page.locator('.file-details__sizes .file-meta').nth(7); await expect(iconMeta).toContainText('16x16'); }); diff --git a/test/uploads/int.spec.ts b/test/uploads/int.spec.ts index 6387028882..80d8b06aba 100644 --- a/test/uploads/int.spec.ts +++ b/test/uploads/int.spec.ts @@ -1,20 +1,20 @@ +import FormData from 'form-data'; import fs from 'fs'; import path from 'path'; -import FormData from 'form-data'; import { promisify } from 'util'; -import { initPayloadTest } from '../helpers/configHelpers'; -import { RESTClient } from '../helpers/rest'; -import configPromise, { mediaSlug, relationSlug } from './config'; import payload from '../../src'; import getFileByPath from '../../src/uploads/getFileByPath'; +import { initPayloadTest } from '../helpers/configHelpers'; +import { RESTClient } from '../helpers/rest'; +import configPromise, { enlargeSlug, mediaSlug, reduceSlug, relationSlug } from './config'; const stat = promisify(fs.stat); require('isomorphic-fetch'); -let client; - describe('Collections - Uploads', () => { + let client: RESTClient; + beforeAll(async () => { const { serverURL } = await initPayloadTest({ __dirname, init: { local: false } }); const config = await configPromise; @@ -32,27 +32,31 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(201); + const { sizes } = doc; + const expectedPath = path.join(__dirname, './media'); + // Check for files - expect(await fileExists(path.join(__dirname, './media', doc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.maintainedAspectRatio.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.tablet.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.mobile.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.icon.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true); + expect( + await fileExists(path.join(expectedPath, sizes.maintainedAspectRatio.filename)), + ).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.tablet.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.mobile.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.icon.filename))).toBe(true); // Check api response expect(doc.mimeType).toEqual('image/png'); - expect(doc.sizes.maintainedAspectRatio.url).toContain('/media/image'); - expect(doc.sizes.maintainedAspectRatio.url).toContain('.png'); - expect(doc.sizes.maintainedAspectRatio.width).toEqual(1024); - expect(doc.sizes.maintainedAspectRatio.height).toEqual(1024); - expect(doc.sizes).toHaveProperty('tablet'); - expect(doc.sizes).toHaveProperty('mobile'); - expect(doc.sizes).toHaveProperty('icon'); + expect(sizes.maintainedAspectRatio.url).toContain('/media/image'); + expect(sizes.maintainedAspectRatio.url).toContain('.png'); + expect(sizes.maintainedAspectRatio.width).toEqual(1024); + expect(sizes.maintainedAspectRatio.height).toEqual(1024); + expect(sizes).toHaveProperty('tablet'); + expect(sizes).toHaveProperty('mobile'); + expect(sizes).toHaveProperty('icon'); }); it('creates from form data given an svg', async () => { @@ -63,7 +67,6 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(201); @@ -87,15 +90,16 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(201); + const expectedPath = path.join(__dirname, './media'); + // Check for files - expect(await fileExists(path.join(__dirname, './media', doc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', 'small-640x480.png'))).toBe(false); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.icon.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false); + expect(await fileExists(path.join(expectedPath, doc.sizes.icon.filename))).toBe(true); // Check api response expect(doc.sizes.tablet.filename).toBeNull(); @@ -110,14 +114,15 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(201); + const expectedPath = path.join(__dirname, './media'); + // Check for files - expect(await fileExists(path.join(__dirname, './media', doc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', doc.sizes.tablet.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, doc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, doc.sizes.tablet.filename))).toBe(true); // Check api response expect(doc.filename).toContain('.png'); @@ -138,7 +143,6 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(201); @@ -149,6 +153,119 @@ describe('Collections - Uploads', () => { // Check api response expect(doc.filename).toBeDefined(); }); + + it('should enlarge images if resize options `withoutEnlargement` is set to false', async () => { + const small = await getFileByPath(path.resolve(__dirname, './small.png')); + + const result = await payload.create({ + collection: enlargeSlug, + data: {}, + file: small, + }); + + expect(result).toBeTruthy(); + + const { sizes } = result; + const expectedPath = path.join(__dirname, './media/enlarge'); + + // Check for files + expect(await fileExists(path.join(expectedPath, small.name))).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.resizedLarger.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.resizedSmaller.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( + true, + ); + expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + true, + ); + + // Check api response + expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg'); + expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg'); + + expect(sizes.resizedLarger.mimeType).toBe('image/png'); + expect(sizes.resizedLarger.filename).toBe('small-640x480.png'); + + expect(sizes.resizedSmaller.mimeType).toBe('image/png'); + expect(sizes.resizedSmaller.filename).toBe('small-180x50.png'); + + expect(sizes.accidentalSameSize.mimeType).toBe('image/png'); + expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png'); + + await payload.delete({ + collection: enlargeSlug, + id: result.id, + }); + }); + + // This test makes sure that the image resizing is not prevented if only one dimension is larger (due to payload preventing enlargement by default) + it('should resize images if one desired dimension is smaller and the other is larger', async () => { + const small = await getFileByPath(path.resolve(__dirname, './small.png')); + + const result = await payload.create({ + collection: enlargeSlug, + data: {}, + file: small, + }); + + expect(result).toBeTruthy(); + + const { sizes } = result; + const expectedPath = path.join(__dirname, './media/enlarge'); + + // Check for files + expect(await fileExists(path.join(expectedPath, sizes.widthLowerHeightLarger.filename))).toBe( + true, + ); + // Check api response + expect(sizes.widthLowerHeightLarger.mimeType).toBe('image/png'); + expect(sizes.widthLowerHeightLarger.filename).toBe('small-300x300.png'); + await payload.delete({ + collection: enlargeSlug, + id: result.id, + }); + }); + + it('should not reduce images if resize options `withoutReduction` is set to true', async () => { + const formData = new FormData(); + formData.append('file', fs.createReadStream(path.join(__dirname, './small.png'))); + const small = await getFileByPath(path.resolve(__dirname, './small.png')); + + const result = await payload.create({ + collection: reduceSlug, + data: {}, + file: small, + }); + + expect(result).toBeTruthy(); + + const { sizes } = result; + const expectedPath = path.join(__dirname, './media/reduce'); + + // Check for files + expect(await fileExists(path.join(expectedPath, small.name))).toBe(true); + expect(await fileExists(path.join(expectedPath, 'small-640x480.png'))).toBe(false); + expect(await fileExists(path.join(expectedPath, 'small-180x50.png'))).toBe(false); + expect(await fileExists(path.join(expectedPath, sizes.accidentalSameSize.filename))).toBe( + true, + ); + expect(await fileExists(path.join(expectedPath, sizes.sameSizeWithNewFormat.filename))).toBe( + true, + ); + + // Check api response + expect(sizes.sameSizeWithNewFormat.mimeType).toBe('image/jpeg'); + expect(sizes.sameSizeWithNewFormat.filename).toBe('small-320x80.jpg'); + + expect(sizes.resizedLarger.mimeType).toBeNull(); + expect(sizes.resizedLarger.filename).toBeNull(); + + expect(sizes.accidentalSameSize.mimeType).toBe('image/png'); + expect(sizes.resizedSmaller.filename).toBe('small-320x80.png'); + + expect(sizes.accidentalSameSize.mimeType).toBe('image/png'); + expect(sizes.accidentalSameSize.filename).toBe('small-320x80.png'); + }); }); it('update', async () => { @@ -168,17 +285,17 @@ describe('Collections - Uploads', () => { const { status } = await client.update({ id: mediaDoc.id, - file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(200); + const expectedPath = path.join(__dirname, './media'); + // Check that previously existing files were removed - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(true); }); it('update - update many', async () => { @@ -201,17 +318,17 @@ describe('Collections - Uploads', () => { where: { id: { equals: mediaDoc.id }, }, - file: true, data: formData, auth: true, - headers: {}, }); expect(status).toBe(200); + const expectedPath = path.join(__dirname, './media'); + // Check that previously existing files were removed - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', mediaDoc.sizes.icon.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.sizes.icon.filename))).toBe(true); }); it('should remove existing media on re-upload', async () => { @@ -226,8 +343,10 @@ describe('Collections - Uploads', () => { file, }); + const expectedPath = path.join(__dirname, './media'); + // Check that the temp file was created - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true); // Replace the temp file with a new one const newFilePath = path.resolve(__dirname, './temp-renamed.png'); @@ -242,8 +361,8 @@ describe('Collections - Uploads', () => { }); // Check that the replacement file was created and the old one was removed - expect(await fileExists(path.join(__dirname, './media', updatedMediaDoc.filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(false); + expect(await fileExists(path.join(expectedPath, updatedMediaDoc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false); }); it('should remove existing media on re-upload - update many', async () => { @@ -258,8 +377,10 @@ describe('Collections - Uploads', () => { file, }); + const expectedPath = path.join(__dirname, './media'); + // Check that the temp file was created - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(true); // Replace the temp file with a new one const newFilePath = path.resolve(__dirname, './temp-renamed.png'); @@ -276,8 +397,8 @@ describe('Collections - Uploads', () => { }); // Check that the replacement file was created and the old one was removed - expect(await fileExists(path.join(__dirname, './media', updatedMediaDoc.docs[0].filename))).toBe(true); - expect(await fileExists(path.join(__dirname, './media', mediaDoc.filename))).toBe(false); + expect(await fileExists(path.join(expectedPath, updatedMediaDoc.docs[0].filename))).toBe(true); + expect(await fileExists(path.join(expectedPath, mediaDoc.filename))).toBe(false); }); it('should remove extra sizes on update', async () => { @@ -394,10 +515,10 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); const { status } = await client.delete(doc.id, { + id: doc.id, auth: true, }); @@ -414,7 +535,6 @@ describe('Collections - Uploads', () => { file: true, data: formData, auth: true, - headers: {}, }); const { errors } = await client.deleteMany({