Skip to content

Commit 6b45fee

Browse files
authored
chore: export getSafeFileName utility (#15424)
Exports the `getSafeFileName` utility from `payload/internal` so we can reuse it in the storage adapters for client side uploads (there's a bug fix in a subsequent PR). Adds some unit tests for this util
1 parent 1f7fe63 commit 6b45fee

File tree

3 files changed

+379
-1
lines changed

3 files changed

+379
-1
lines changed

packages/payload/src/exports/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
*/
44

55
export { getRangeRequestInfo } from '../uploads/getRangeRequestInfo.js'
6+
export { getSafeFileName } from '../uploads/getSafeFilename.js'
67
export { parseRangeHeader } from '../uploads/parseRangeHeader.js'
78
export { getEntityPermissions } from '../utilities/getEntityPermissions/getEntityPermissions.js'
89
export { sanitizePermissions } from '../utilities/sanitizePermissions.js'
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
import { beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { getSafeFileName, incrementName } from './getSafeFilename.js'
4+
5+
vi.mock('./docWithFilenameExists.js', () => ({
6+
docWithFilenameExists: vi.fn(),
7+
}))
8+
9+
vi.mock('./fileExists.js', () => ({
10+
fileExists: vi.fn(),
11+
}))
12+
13+
import { docWithFilenameExists } from './docWithFilenameExists.js'
14+
import { fileExists } from './fileExists.js'
15+
16+
const mockDocWithFilenameExists = vi.mocked(docWithFilenameExists)
17+
const mockFileExists = vi.mocked(fileExists)
18+
19+
describe('incrementName', () => {
20+
it('should add -1 suffix to filename without existing suffix', () => {
21+
expect(incrementName('photo.jpg')).toBe('photo-1.jpg')
22+
})
23+
24+
it('should increment existing numeric suffix', () => {
25+
expect(incrementName('photo-1.jpg')).toBe('photo-2.jpg')
26+
})
27+
28+
it('should handle multi-digit suffixes', () => {
29+
expect(incrementName('photo-99.jpg')).toBe('photo-100.jpg')
30+
})
31+
32+
it('should handle filenames with multiple dots', () => {
33+
expect(incrementName('my.photo.name.jpg')).toBe('my.photo.name-1.jpg')
34+
})
35+
36+
it('should handle filenames with hyphens but no numeric suffix', () => {
37+
expect(incrementName('my-photo.jpg')).toBe('my-photo-1.jpg')
38+
})
39+
40+
it('should handle filenames with hyphens and numeric suffix', () => {
41+
expect(incrementName('my-photo-5.jpg')).toBe('my-photo-6.jpg')
42+
})
43+
44+
it('should preserve the original extension', () => {
45+
expect(incrementName('document.pdf')).toBe('document-1.pdf')
46+
expect(incrementName('image.png')).toBe('image-1.png')
47+
})
48+
49+
it('should handle files with long extensions', () => {
50+
expect(incrementName('archive.tar.gz')).toBe('archive.tar-1.gz')
51+
})
52+
53+
it('should handle filename with no extension', () => {
54+
expect(incrementName('filename')).toBe('filename-1.filename')
55+
})
56+
57+
it('should handle filename that is just an extension', () => {
58+
expect(incrementName('.gitignore')).toBe('.gitignore-1.gitignore')
59+
})
60+
61+
it('should handle empty string', () => {
62+
expect(incrementName('')).toBe('-1.')
63+
})
64+
65+
it('should handle filename ending with hyphen and non-numeric', () => {
66+
expect(incrementName('file-abc.jpg')).toBe('file-abc-1.jpg')
67+
})
68+
69+
it('should handle filename with only numbers before extension', () => {
70+
expect(incrementName('12345.jpg')).toBe('12345-1.jpg')
71+
})
72+
73+
it('should handle filename where base ends with -0', () => {
74+
expect(incrementName('file-0.jpg')).toBe('file-1.jpg')
75+
})
76+
77+
it('should preserve jpeg extension', () => {
78+
expect(incrementName('photo.jpeg')).toBe('photo-1.jpeg')
79+
expect(incrementName('photo-1.jpeg')).toBe('photo-2.jpeg')
80+
})
81+
82+
it('should preserve jpg extension', () => {
83+
expect(incrementName('photo.jpg')).toBe('photo-1.jpg')
84+
expect(incrementName('photo-1.jpg')).toBe('photo-2.jpg')
85+
})
86+
87+
it('should preserve tiff extension', () => {
88+
expect(incrementName('image.tiff')).toBe('image-1.tiff')
89+
expect(incrementName('image-1.tiff')).toBe('image-2.tiff')
90+
})
91+
92+
it('should preserve tif extension', () => {
93+
expect(incrementName('image.tif')).toBe('image-1.tif')
94+
expect(incrementName('image-1.tif')).toBe('image-2.tif')
95+
})
96+
97+
it('should handle common image formats', () => {
98+
expect(incrementName('image.png')).toBe('image-1.png')
99+
expect(incrementName('image.gif')).toBe('image-1.gif')
100+
expect(incrementName('image.webp')).toBe('image-1.webp')
101+
expect(incrementName('image.avif')).toBe('image-1.avif')
102+
expect(incrementName('image.svg')).toBe('image-1.svg')
103+
expect(incrementName('image.bmp')).toBe('image-1.bmp')
104+
expect(incrementName('image.ico')).toBe('image-1.ico')
105+
})
106+
107+
it('should handle common document formats', () => {
108+
expect(incrementName('document.pdf')).toBe('document-1.pdf')
109+
expect(incrementName('document.doc')).toBe('document-1.doc')
110+
expect(incrementName('document.docx')).toBe('document-1.docx')
111+
expect(incrementName('spreadsheet.xls')).toBe('spreadsheet-1.xls')
112+
expect(incrementName('spreadsheet.xlsx')).toBe('spreadsheet-1.xlsx')
113+
expect(incrementName('presentation.ppt')).toBe('presentation-1.ppt')
114+
expect(incrementName('presentation.pptx')).toBe('presentation-1.pptx')
115+
})
116+
117+
it('should handle common video formats', () => {
118+
expect(incrementName('video.mp4')).toBe('video-1.mp4')
119+
expect(incrementName('video.mov')).toBe('video-1.mov')
120+
expect(incrementName('video.avi')).toBe('video-1.avi')
121+
expect(incrementName('video.webm')).toBe('video-1.webm')
122+
expect(incrementName('video.mkv')).toBe('video-1.mkv')
123+
})
124+
125+
it('should handle common audio formats', () => {
126+
expect(incrementName('audio.mp3')).toBe('audio-1.mp3')
127+
expect(incrementName('audio.wav')).toBe('audio-1.wav')
128+
expect(incrementName('audio.ogg')).toBe('audio-1.ogg')
129+
expect(incrementName('audio.flac')).toBe('audio-1.flac')
130+
expect(incrementName('audio.aac')).toBe('audio-1.aac')
131+
})
132+
133+
it('should handle uppercase extensions', () => {
134+
expect(incrementName('PHOTO.JPG')).toBe('PHOTO-1.JPG')
135+
expect(incrementName('PHOTO.JPEG')).toBe('PHOTO-1.JPEG')
136+
expect(incrementName('IMAGE.PNG')).toBe('IMAGE-1.PNG')
137+
})
138+
139+
it('should handle mixed case extensions', () => {
140+
expect(incrementName('photo.Jpeg')).toBe('photo-1.Jpeg')
141+
expect(incrementName('image.Png')).toBe('image-1.Png')
142+
})
143+
})
144+
145+
describe('getSafeFileName', () => {
146+
const mockReq = {} as Parameters<typeof getSafeFileName>[0]['req']
147+
148+
beforeEach(() => {
149+
vi.clearAllMocks()
150+
})
151+
152+
it('should return original filename when no conflicts exist', async () => {
153+
mockDocWithFilenameExists.mockResolvedValue(false)
154+
mockFileExists.mockResolvedValue(false)
155+
156+
const result = await getSafeFileName({
157+
collectionSlug: 'media',
158+
desiredFilename: 'photo.jpg',
159+
req: mockReq,
160+
staticPath: '/uploads',
161+
})
162+
163+
expect(result).toBe('photo.jpg')
164+
expect(mockDocWithFilenameExists).toHaveBeenCalledTimes(1)
165+
expect(mockFileExists).toHaveBeenCalledTimes(1)
166+
})
167+
168+
it('should increment filename when document exists in database', async () => {
169+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
170+
mockFileExists.mockResolvedValue(false)
171+
172+
const result = await getSafeFileName({
173+
collectionSlug: 'media',
174+
desiredFilename: 'photo.jpg',
175+
req: mockReq,
176+
staticPath: '/uploads',
177+
})
178+
179+
expect(result).toBe('photo-1.jpg')
180+
})
181+
182+
it('should increment filename when file exists on filesystem', async () => {
183+
mockDocWithFilenameExists.mockResolvedValue(false)
184+
mockFileExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
185+
186+
const result = await getSafeFileName({
187+
collectionSlug: 'media',
188+
desiredFilename: 'photo.jpg',
189+
req: mockReq,
190+
staticPath: '/uploads',
191+
})
192+
193+
expect(result).toBe('photo-1.jpg')
194+
})
195+
196+
it('should keep incrementing until unique name is found', async () => {
197+
mockDocWithFilenameExists
198+
.mockResolvedValueOnce(true)
199+
.mockResolvedValueOnce(true)
200+
.mockResolvedValueOnce(true)
201+
.mockResolvedValueOnce(false)
202+
mockFileExists.mockResolvedValue(false)
203+
204+
const result = await getSafeFileName({
205+
collectionSlug: 'media',
206+
desiredFilename: 'photo.jpg',
207+
req: mockReq,
208+
staticPath: '/uploads',
209+
})
210+
211+
expect(result).toBe('photo-3.jpg')
212+
})
213+
214+
it('should handle conflicts from both database and filesystem', async () => {
215+
mockDocWithFilenameExists
216+
.mockResolvedValueOnce(true)
217+
.mockResolvedValueOnce(false)
218+
.mockResolvedValueOnce(false)
219+
mockFileExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
220+
221+
const result = await getSafeFileName({
222+
collectionSlug: 'media',
223+
desiredFilename: 'photo.jpg',
224+
req: mockReq,
225+
staticPath: '/uploads',
226+
})
227+
228+
expect(result).toBe('photo-2.jpg')
229+
})
230+
231+
it('should pass prefix to docWithFilenameExists', async () => {
232+
mockDocWithFilenameExists.mockResolvedValue(false)
233+
mockFileExists.mockResolvedValue(false)
234+
235+
await getSafeFileName({
236+
collectionSlug: 'media',
237+
desiredFilename: 'photo.jpg',
238+
prefix: 'images/',
239+
req: mockReq,
240+
staticPath: '/uploads',
241+
})
242+
243+
expect(mockDocWithFilenameExists).toHaveBeenCalledWith({
244+
collectionSlug: 'media',
245+
filename: 'photo.jpg',
246+
path: '/uploads',
247+
prefix: 'images/',
248+
req: mockReq,
249+
})
250+
})
251+
252+
it('should check correct filesystem path', async () => {
253+
mockDocWithFilenameExists.mockResolvedValue(false)
254+
mockFileExists.mockResolvedValue(false)
255+
256+
await getSafeFileName({
257+
collectionSlug: 'media',
258+
desiredFilename: 'photo.jpg',
259+
req: mockReq,
260+
staticPath: '/var/uploads/media',
261+
})
262+
263+
expect(mockFileExists).toHaveBeenCalledWith('/var/uploads/media/photo.jpg')
264+
})
265+
266+
it('should handle filename that already has numeric suffix', async () => {
267+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
268+
mockFileExists.mockResolvedValue(false)
269+
270+
const result = await getSafeFileName({
271+
collectionSlug: 'media',
272+
desiredFilename: 'photo-5.jpg',
273+
req: mockReq,
274+
staticPath: '/uploads',
275+
})
276+
277+
expect(result).toBe('photo-6.jpg')
278+
})
279+
280+
it('should preserve jpeg extension when incrementing', async () => {
281+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
282+
mockFileExists.mockResolvedValue(false)
283+
284+
const result = await getSafeFileName({
285+
collectionSlug: 'media',
286+
desiredFilename: 'photo.jpeg',
287+
req: mockReq,
288+
staticPath: '/uploads',
289+
})
290+
291+
expect(result).toBe('photo-1.jpeg')
292+
})
293+
294+
it('should preserve tiff extension when incrementing', async () => {
295+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
296+
mockFileExists.mockResolvedValue(false)
297+
298+
const result = await getSafeFileName({
299+
collectionSlug: 'media',
300+
desiredFilename: 'image.tiff',
301+
req: mockReq,
302+
staticPath: '/uploads',
303+
})
304+
305+
expect(result).toBe('image-1.tiff')
306+
})
307+
308+
it('should handle webp files', async () => {
309+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
310+
mockFileExists.mockResolvedValue(false)
311+
312+
const result = await getSafeFileName({
313+
collectionSlug: 'media',
314+
desiredFilename: 'image.webp',
315+
req: mockReq,
316+
staticPath: '/uploads',
317+
})
318+
319+
expect(result).toBe('image-1.webp')
320+
})
321+
322+
it('should handle pdf files', async () => {
323+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
324+
mockFileExists.mockResolvedValue(false)
325+
326+
const result = await getSafeFileName({
327+
collectionSlug: 'media',
328+
desiredFilename: 'document.pdf',
329+
req: mockReq,
330+
staticPath: '/uploads',
331+
})
332+
333+
expect(result).toBe('document-1.pdf')
334+
})
335+
336+
it('should handle mp4 video files', async () => {
337+
mockDocWithFilenameExists.mockResolvedValueOnce(true).mockResolvedValueOnce(false)
338+
mockFileExists.mockResolvedValue(false)
339+
340+
const result = await getSafeFileName({
341+
collectionSlug: 'media',
342+
desiredFilename: 'video.mp4',
343+
req: mockReq,
344+
staticPath: '/uploads',
345+
})
346+
347+
expect(result).toBe('video-1.mp4')
348+
})
349+
})

0 commit comments

Comments
 (0)