diff --git a/apps/core/src/modules/configs/configs.default.ts b/apps/core/src/modules/configs/configs.default.ts index d7db2cb591d..4813b9a4e80 100644 --- a/apps/core/src/modules/configs/configs.default.ts +++ b/apps/core/src/modules/configs/configs.default.ts @@ -74,6 +74,11 @@ export const generateDefaultConfig: () => IConfig = () => ({ customDomain: '', prefix: '', }, + fileUploadOptions: { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + pathTemplate: '{type}', + }, baiduSearchOptions: { enable: false, token: null! }, bingSearchOptions: { enable: false, token: null! }, algoliaSearchOptions: { diff --git a/apps/core/src/modules/configs/configs.interface.ts b/apps/core/src/modules/configs/configs.interface.ts index 6319105ecba..722c1bd0311 100644 --- a/apps/core/src/modules/configs/configs.interface.ts +++ b/apps/core/src/modules/configs/configs.interface.ts @@ -11,6 +11,7 @@ import { type BingSearchOptionsSchema, type CommentOptionsSchema, type FeatureListSchema, + type FileUploadOptionsSchema, type FriendLinkOptionsSchema, type ImageStorageOptionsSchema, type MailOptionsSchema, @@ -39,6 +40,7 @@ export abstract class IConfig { friendLinkOptions: Required> backupOptions: Required> imageStorageOptions: Required> + fileUploadOptions: Required> baiduSearchOptions: Required> bingSearchOptions: Required> algoliaSearchOptions: Required> diff --git a/apps/core/src/modules/configs/configs.schema.ts b/apps/core/src/modules/configs/configs.schema.ts index 768a951c6e6..b9e24331ee8 100644 --- a/apps/core/src/modules/configs/configs.schema.ts +++ b/apps/core/src/modules/configs/configs.schema.ts @@ -155,7 +155,8 @@ export const ImageStorageOptionsSchema = section('图床设置', { }, ), prefix: field.plain(z.string().optional(), '文件路径前缀', { - description: '上传到 S3 的文件路径前缀,例如 images/', + description: + '上传到 S3 的文件路径前缀,支持模板占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {md5}随机MD5, {type}文件类型等。例如: blog/{Y}/{m}/{d} 或 images/', }), }) export class ImageStorageOptionsDto extends createZodDto( @@ -165,6 +166,29 @@ export type ImageStorageOptionsConfig = z.infer< typeof ImageStorageOptionsSchema > +// ==================== File Upload Options ==================== +export const FileUploadOptionsSchema = section('文件上传设定', { + enableCustomNaming: field.toggle( + z.boolean().optional(), + '启用自定义文件命名', + { + description: '开启后将使用下方的命名模板规则', + }, + ), + filenameTemplate: field.plain(z.string().optional(), '文件名模板', { + description: + '支持占位符: {Y}年4位, {y}年2位, {m}月, {d}日, {h}时, {i}分, {s}秒, {ms}毫秒, {timestamp}时间戳, {md5}随机MD5, {md5-16}随机MD5(16位), {uuid}UUID, {str-数字}随机字符串, {filename}原文件名(含扩展名), {name}原文件名(不含扩展名), {ext}扩展名', + }), + pathTemplate: field.plain(z.string().optional(), '文件路径模板', { + description: + '支持占位符同文件名模板,另外支持 {type} 文件类型, {localFolder:数字} 原文件所在文件夹层级', + }), +}) +export class FileUploadOptionsDto extends createZodDto( + FileUploadOptionsSchema, +) {} +export type FileUploadOptionsConfig = z.infer + // ==================== Baidu Search Options ==================== export const BaiduSearchOptionsSchema = section('百度推送设定', { enable: field.toggle(z.boolean().optional(), '开启推送'), @@ -421,6 +445,7 @@ export const configSchemaMapping = { friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, @@ -446,6 +471,7 @@ export const FullConfigSchema = withMeta( friendLinkOptions: FriendLinkOptionsSchema, backupOptions: BackupOptionsSchema, imageStorageOptions: ImageStorageOptionsSchema, + fileUploadOptions: FileUploadOptionsSchema, baiduSearchOptions: BaiduSearchOptionsSchema, bingSearchOptions: BingSearchOptionsSchema, algoliaSearchOptions: AlgoliaSearchOptionsSchema, diff --git a/apps/core/src/modules/file/file.controller.ts b/apps/core/src/modules/file/file.controller.ts index 166f35185e3..40a7f1c545b 100644 --- a/apps/core/src/modules/file/file.controller.ts +++ b/apps/core/src/modules/file/file.controller.ts @@ -17,15 +17,18 @@ import { ApiController } from '~/common/decorators/api-controller.decorator' import { Auth } from '~/common/decorators/auth.decorator' import { HTTPDecorators } from '~/common/decorators/http.decorator' import { CannotFindException } from '~/common/exceptions/cant-find.exception' -import { alphabet } from '~/constants/other.constant' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import { UploadService } from '~/processors/helper/helper.upload.service' import { PagerDto } from '~/shared/dto/pager.dto' +import { + generateFilename, + generateFilePath, + replaceFilenameTemplate, +} from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import type { FastifyReply, FastifyRequest } from 'fastify' import { lookup } from 'mime-types' -import { customAlphabet } from 'nanoid' import { FileReferenceService } from './file-reference.service' import { BatchOrphanDeleteDto, @@ -113,9 +116,17 @@ export class FileController { const buffer = await fs.readFile(localPath) const contentType = lookup(filename) || 'application/octet-stream' - const objectKey = config.prefix - ? `${config.prefix.replace(/\/+$/, '')}/${filename}` - : filename + // 处理 prefix 中的模板变量 + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename: filename, + fileType: 'image', + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename const s3Url = await s3Uploader.uploadBuffer( buffer, @@ -141,7 +152,7 @@ export class FileController { } private extractLocalImageFilename(url: string): string | null { - const match = url.match(/\/objects\/image\/([^/?#]+)/) + const match = url.match(/\/objects\/image\/([^#/?]+)/) return match ? match[1] : null } @@ -248,20 +259,50 @@ export class FileController { const file = await this.uploadService.getAndValidMultipartField(req) const { type = 'file' } = query - const ext = path.extname(file.filename) - const filename = customAlphabet(alphabet)(18) + ext.toLowerCase() + // 获取文件上传配置 + const uploadConfig = await this.configsService.get('fileUploadOptions') + + // 生成文件名(可能包含子路径) + const rawFilename = generateFilename(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 生成基础路径 + const basePath = generateFilePath(uploadConfig, { + originalFilename: file.filename, + fileType: type, + }) + + // 构建相对路径(相对于文件类型目录) + // 如果 basePath 就是 type,则直接使用 rawFilename + // 否则,将 basePath 中除去 type 部分后与 rawFilename 合并 + let relativePath: string + if (basePath === type || !basePath) { + // basePath 就是 type 或为空,直接使用 rawFilename + relativePath = rawFilename + } else { + // basePath 包含自定义路径,需要去除开头的 type 部分 + const pathWithoutType = basePath.startsWith(`${type}/`) + ? basePath.slice(Math.max(0, type.length + 1)) + : basePath + relativePath = path.join(pathWithoutType, rawFilename) + } - await this.service.writeFile(type, filename, file.file) + await this.service.writeFile(type, relativePath, file.file) - const fileUrl = await this.service.resolveFileUrl(type, filename) + const fileUrl = await this.service.resolveFileUrl(type, relativePath) if (type === 'image') { - await this.fileReferenceService.createPendingReference(fileUrl, filename) + await this.fileReferenceService.createPendingReference( + fileUrl, + relativePath, + ) } return { url: fileUrl, - name: filename, + name: path.basename(relativePath), } } diff --git a/apps/core/src/processors/helper/helper.image-migration.service.ts b/apps/core/src/processors/helper/helper.image-migration.service.ts index 9f3cb142bd0..8b26705f870 100644 --- a/apps/core/src/processors/helper/helper.image-migration.service.ts +++ b/apps/core/src/processors/helper/helper.image-migration.service.ts @@ -4,6 +4,7 @@ import { Injectable, Logger } from '@nestjs/common' import { STATIC_FILE_DIR } from '~/constants/path.constant' import { ConfigsService } from '~/modules/configs/configs.service' import type { ImageModel } from '~/shared/model/image.model' +import { replaceFilenameTemplate } from '~/utils/filename-template.util' import { S3Uploader } from '~/utils/s3.util' import { lookup } from 'mime-types' @@ -25,7 +26,7 @@ export class ImageMigrationService { } extractLocalImageFilename(url: string): string | null { - const match = url.match(/\/objects\/image\/([^/?#]+)/) + const match = url.match(/\/objects\/image\/([^#/?]+)/) return match ? match[1] : null } @@ -95,9 +96,17 @@ export class ImageMigrationService { const buffer = await readFile(localPath) const contentType = lookup(filename) || 'application/octet-stream' - const objectKey = config.prefix - ? `${config.prefix.replace(/\/+$/, '')}/${filename}` - : filename + // 处理 prefix 中的模板变量 + let prefixPath = '' + if (config.prefix) { + prefixPath = replaceFilenameTemplate(config.prefix, { + originalFilename: filename, + fileType: 'image', + }) + prefixPath = prefixPath.replace(/\/+$/, '') + } + + const objectKey = prefixPath ? `${prefixPath}/${filename}` : filename const s3Url = await s3Uploader.uploadBuffer( buffer, diff --git a/apps/core/src/utils/filename-template.util.ts b/apps/core/src/utils/filename-template.util.ts new file mode 100644 index 00000000000..2c4531776c9 --- /dev/null +++ b/apps/core/src/utils/filename-template.util.ts @@ -0,0 +1,189 @@ +import crypto from 'node:crypto' +import path from 'node:path' +import { alphabet } from '~/constants/other.constant' +import { customAlphabet } from 'nanoid' + +/** + * 文件名模板占位符替换工具 + * 支持的占位符: + * - {Y} 年份 (4位) + * - {y} 年份 (2位) + * - {m} 月份 (2位) + * - {d} 日期 (2位) + * - {h} 小时 (2位) + * - {i} 分钟 (2位) + * - {s} 秒钟 (2位) + * - {ms} 毫秒 (3位) + * - {timestamp} 时间戳 (毫秒) + * - {md5} 随机MD5字符串 (32位) + * - {md5-16} 随机MD5字符串 (16位) + * - {uuid} UUID字符串 + * - {str-数字} 随机字符串,数字表示长度 + * - {filename} 原文件名 (包含扩展名) + * - {name} 原文件名 (不含扩展名) + * - {ext} 扩展名 (包含点号) + * - {type} 文件类型 + * - {localFolder:数字} 原文件所在文件夹 (数字表示层级) + */ +export interface FilenameTemplateContext { + /** + * 原始文件名 (包含扩展名) + */ + originalFilename: string + + /** + * 文件类型 (如: image, file, avatar, icon) + */ + fileType?: string + + /** + * 本地文件夹路径 (用于 localFolder 占位符) + */ + localFolderPath?: string +} + +/** + * 生成一个随机的 MD5 字符串 + */ +function generateRandomMd5(): string { + return crypto.randomBytes(16).toString('hex') +} + +/** + * 生成一个 UUID v4 字符串 + */ +function generateUuid(): string { + return crypto.randomUUID() +} + +/** + * 格式化数字,补零到指定位数 + */ +function padZero(num: number, length: number): string { + return num.toString().padStart(length, '0') +} + +/** + * 提取文件名中的文件夹层级 + * @param folderPath 文件夹路径 + * @param level 提取的层级数 + */ +function extractFolderLevel( + folderPath: string | undefined, + level: number, +): string { + if (!folderPath) return '' + + const parts = folderPath.split(/[/\\]/).filter(Boolean) + if (level <= 0 || level > parts.length) return '' + + return parts.slice(-level).join('/') +} + +/** + * 替换模板中的占位符 + * @param template 模板字符串 + * @param context 上下文信息 + * @returns 替换后的字符串 + */ +export function replaceFilenameTemplate( + template: string, + context: FilenameTemplateContext, +): string { + const now = new Date() + const { originalFilename, fileType = '', localFolderPath } = context + + // 提取文件名和扩展名 + const ext = path.extname(originalFilename).toLowerCase() + const nameWithoutExt = path.basename(originalFilename, ext) + + let result = template + + // 时间相关占位符 + result = result.replaceAll('{Y}', now.getFullYear().toString()) + result = result.replaceAll('{y}', padZero(now.getFullYear() % 100, 2)) + result = result.replaceAll('{m}', padZero(now.getMonth() + 1, 2)) + result = result.replaceAll('{d}', padZero(now.getDate(), 2)) + result = result.replaceAll('{h}', padZero(now.getHours(), 2)) + result = result.replaceAll('{i}', padZero(now.getMinutes(), 2)) + result = result.replaceAll('{s}', padZero(now.getSeconds(), 2)) + result = result.replaceAll('{ms}', padZero(now.getMilliseconds(), 3)) + result = result.replaceAll('{timestamp}', now.getTime().toString()) + + // 随机字符串占位符 + result = result.replaceAll('{md5}', () => generateRandomMd5()) + result = result.replaceAll('{md5-16}', () => generateRandomMd5().slice(0, 16)) + result = result.replaceAll('{uuid}', () => generateUuid()) + + // 自定义长度的随机字符串 {str-数字} + // eslint-disable-next-line unicorn/better-regex + result = result.replaceAll(/\{str-(\d+)\}/g, (_match, length) => { + const len = Number.parseInt(length, 10) + return customAlphabet(alphabet)(len) + }) + + // 文件名相关占位符 + result = result.replaceAll('{filename}', originalFilename) + result = result.replaceAll('{name}', nameWithoutExt) + result = result.replaceAll('{ext}', ext) + + // 文件类型占位符 + result = result.replaceAll('{type}', fileType) + + // 本地文件夹占位符 {localFolder:数字} + // eslint-disable-next-line unicorn/better-regex + result = result.replaceAll(/\{localFolder:(\d+)\}/g, (_match, level) => { + const lvl = Number.parseInt(level, 10) + return extractFolderLevel(localFolderPath, lvl) + }) + + // 防止路径遍历:移除父目录引用(..) + const segments = result.split(/[/\\]+/) + const safeSegments = segments.filter(segment => segment !== '..') + const safeResult = safeSegments.join('/') + + return safeResult +} + +/** + * 生成文件名(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的文件名 + */ +export function generateFilename( + config: { + enableCustomNaming?: boolean + filenameTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有模板,使用默认规则 + if (!config.enableCustomNaming || !config.filenameTemplate) { + const ext = path.extname(context.originalFilename).toLowerCase() + return customAlphabet(alphabet)(18) + ext + } + + return replaceFilenameTemplate(config.filenameTemplate, context) +} + +/** + * 生成文件路径(应用模板或使用默认规则) + * @param config 配置对象 + * @param context 上下文信息 + * @returns 生成的路径 + */ +export function generateFilePath( + config: { + enableCustomNaming?: boolean + pathTemplate?: string + }, + context: FilenameTemplateContext, +): string { + // 如果未启用自定义命名或没有路径模板,使用默认规则(文件类型) + if (!config.enableCustomNaming || !config.pathTemplate) { + return context.fileType || '' + } + + return replaceFilenameTemplate(config.pathTemplate, context) +} diff --git a/apps/core/src/utils/s3.util.ts b/apps/core/src/utils/s3.util.ts index 89ddb0bf848..e4d9fff0250 100644 --- a/apps/core/src/utils/s3.util.ts +++ b/apps/core/src/utils/s3.util.ts @@ -111,17 +111,39 @@ export class S3Uploader { // Set request headers const url = new URL(this.endpoint) const host = url.host - const contentLength = fileData.length.toString() + let requestHost = host + let canonicalUri: string // URI encode each path segment for signing const encodedObjectKey = objectKey .split('/') .map((seg) => encodeURIComponent(seg)) .join('/') - const canonicalUri = `/${this.bucket}/${encodedObjectKey}` + + // Determine canonical URI based on endpoint style + // For Tencent COS and other services that require virtual-hosted style + const isTencentCos = host.includes('myqcloud.com') || host.includes('.cos.') + + if (isTencentCos) { + // Tencent COS requires virtual-hosted style + // Convert cos.ap-guangzhou.myqcloud.com to bucket.cos.ap-guangzhou.myqcloud.com + const cosMatch = host.match(/^cos\.(.+)$/) + if (cosMatch) { + requestHost = `${this.bucket}.cos.${cosMatch[1]}` + } + canonicalUri = `/${encodedObjectKey}` + } else { + // Check if already in virtual-hosted style + const isVirtualHosted = host.startsWith(`${this.bucket}.`) + canonicalUri = isVirtualHosted + ? `/${encodedObjectKey}` + : `/${this.bucket}/${encodedObjectKey}` + } + + const contentLength = fileData.length.toString() const headers: Record = { - Host: host, + Host: requestHost, 'Content-Type': contentType, 'Content-Length': contentLength, 'x-amz-date': xAmzDate, @@ -173,7 +195,11 @@ export class S3Uploader { const authorization = `${algorithm} Credential=${this.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}` // Create and send PUT request - const requestUrl = `${this.endpoint}${canonicalUri}` + // For Tencent COS, use virtual-hosted style URL + const baseUrl = isTencentCos + ? `${url.protocol}//${requestHost}` + : this.endpoint + const requestUrl = `${baseUrl}${canonicalUri}` const fetchOptions: RequestInit & { dispatcher?: unknown } = { method: 'PUT', @@ -194,7 +220,10 @@ export class S3Uploader { const response = await fetch(requestUrl, fetchOptions as RequestInit) if (!response.ok) { - throw new Error(`Upload failed with status code: ${response.status}`) + const responseText = await response.text() + throw new Error( + `Upload failed with status code: ${response.status} - ${responseText}`, + ) } } finally { if (isDev) { diff --git a/apps/core/test/src/utils/filename-template.util.spec.ts b/apps/core/test/src/utils/filename-template.util.spec.ts new file mode 100644 index 00000000000..a9089e529e0 --- /dev/null +++ b/apps/core/test/src/utils/filename-template.util.spec.ts @@ -0,0 +1,167 @@ +import { + generateFilename, + generateFilePath, + replaceFilenameTemplate, +} from '~/utils/filename-template.util' +import { describe, expect, it } from 'vitest' + +describe('Filename Template Util', () => { + const mockContext = { + originalFilename: 'test-photo.jpg', + fileType: 'image', + localFolderPath: 'photos/2026/vacation', + } + + describe('replaceFilenameTemplate', () => { + it('应该替换文件名占位符', () => { + const result = replaceFilenameTemplate('{filename}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件名(不含扩展名)和扩展名', () => { + const result = replaceFilenameTemplate('{name}{ext}', mockContext) + expect(result).toBe('test-photo.jpg') + }) + + it('应该替换文件类型', () => { + const result = replaceFilenameTemplate('{type}/{filename}', mockContext) + expect(result).toBe('image/test-photo.jpg') + }) + + it('应该替换年份占位符', () => { + const template = '{Y}/{y}/{filename}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{4}\/\d{2}\/test-photo\.jpg$/) + }) + + it('应该替换日期时间占位符', () => { + const template = '{Y}{m}{d}_{h}{i}{s}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^\d{8}_\d{6}\.jpg$/) + }) + + it('应该生成MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{32}\.jpg$/) + }) + + it('应该生成16位MD5随机字符串', () => { + const result = replaceFilenameTemplate('{md5-16}{ext}', mockContext) + expect(result).toMatch(/^[a-f0-9]{16}\.jpg$/) + }) + + it('应该生成UUID', () => { + const result = replaceFilenameTemplate('{uuid}{ext}', mockContext) + expect(result).toMatch( + /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}\.jpg$/, + ) + }) + + it('应该生成自定义长度的随机字符串', () => { + const result = replaceFilenameTemplate('{str-8}{ext}', mockContext) + expect(result).toMatch(/^.{8}\.jpg$/) + }) + + it('应该生成时间戳', () => { + const result = replaceFilenameTemplate('{timestamp}{ext}', mockContext) + expect(result).toMatch(/^\d+\.jpg$/) + }) + + it('应该提取文件夹层级', () => { + const result1 = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + mockContext, + ) + expect(result1).toBe('vacation/test-photo.jpg') + + const result2 = replaceFilenameTemplate( + '{localFolder:2}/{filename}', + mockContext, + ) + expect(result2).toBe('2026/vacation/test-photo.jpg') + + const result3 = replaceFilenameTemplate( + '{localFolder:3}/{filename}', + mockContext, + ) + expect(result3).toBe('photos/2026/vacation/test-photo.jpg') + }) + + it('应该处理没有本地文件夹路径的情况', () => { + const contextWithoutFolder = { + ...mockContext, + localFolderPath: undefined, + } + const result = replaceFilenameTemplate( + '{localFolder:1}/{filename}', + contextWithoutFolder, + ) + expect(result).toBe('/test-photo.jpg') + }) + + it('应该正确处理复杂模板', () => { + const template = '{type}/{Y}/{m}/{d}/{md5-16}{ext}' + const result = replaceFilenameTemplate(template, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}\/\d{2}\/[a-f0-9]{16}\.jpg$/) + }) + }) + + describe('generateFilename', () => { + it('未启用自定义命名时应该生成默认文件名', () => { + const config = { + enableCustomNaming: false, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + // 默认文件名是18位随机字符 + 扩展名 + expect(result).toMatch(/^.{18}\.jpg$/) + }) + + it('启用自定义命名时应该使用模板', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: '{Y}{m}{d}/{md5-16}{ext}', + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^\d{8}\/[a-f0-9]{16}\.jpg$/) + }) + + it('没有模板时应该使用默认规则', () => { + const config = { + enableCustomNaming: true, + filenameTemplate: undefined, + } + const result = generateFilename(config, mockContext) + expect(result).toMatch(/^.{18}\.jpg$/) + }) + }) + + describe('generateFilePath', () => { + it('未启用自定义命名时应该返回文件类型', () => { + const config = { + enableCustomNaming: false, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + + it('启用自定义命名时应该使用路径模板', () => { + const config = { + enableCustomNaming: true, + pathTemplate: '{type}/{Y}/{m}', + } + const result = generateFilePath(config, mockContext) + expect(result).toMatch(/^image\/\d{4}\/\d{2}$/) + }) + + it('没有路径模板时应该返回文件类型', () => { + const config = { + enableCustomNaming: true, + pathTemplate: undefined, + } + const result = generateFilePath(config, mockContext) + expect(result).toBe('image') + }) + }) +})