1- import type { Payload } from 'payload'
1+ import type { CollectionSlug , Payload } from 'payload'
22
33import * as AWS from '@aws-sdk/client-s3'
44import path from 'path'
@@ -7,7 +7,13 @@ import { fileURLToPath } from 'url'
77import type { NextRESTClient } from '../helpers/NextRESTClient.js'
88
99import { initPayloadInt } from '../helpers/initPayloadInt.js'
10- import { mediaSlug , mediaWithPrefixSlug , mediaWithSignedDownloadsSlug , prefix } from './shared.js'
10+ import {
11+ mediaSlug ,
12+ mediaWithDynamicPrefixSlug ,
13+ mediaWithPrefixSlug ,
14+ mediaWithSignedDownloadsSlug ,
15+ prefix ,
16+ } from './shared.js'
1117
1218const filename = fileURLToPath ( import . meta. url )
1319const dirname = path . dirname ( filename )
@@ -22,15 +28,15 @@ describe('@payloadcms/storage-s3', () => {
2228
2329 beforeAll ( async ( ) => {
2430 ; ( { payload, restClient } = await initPayloadInt ( dirname ) )
25- TEST_BUCKET = process . env . S3_BUCKET
31+ TEST_BUCKET = process . env . S3_BUCKET !
2632
2733 client = new AWS . S3 ( {
28- endpoint : process . env . S3_ENDPOINT ,
34+ endpoint : process . env . S3_ENDPOINT ! ,
2935 forcePathStyle : process . env . S3_FORCE_PATH_STYLE === 'true' ,
30- region : process . env . S3_REGION ,
36+ region : process . env . S3_REGION ! ,
3137 credentials : {
32- accessKeyId : process . env . S3_ACCESS_KEY_ID ,
33- secretAccessKey : process . env . S3_SECRET_ACCESS_KEY ,
38+ accessKeyId : process . env . S3_ACCESS_KEY_ID ! ,
39+ secretAccessKey : process . env . S3_SECRET_ACCESS_KEY ! ,
3440 } ,
3541 } )
3642
@@ -119,6 +125,117 @@ describe('@payloadcms/storage-s3', () => {
119125 it . todo ( 'can upload' )
120126 } )
121127
128+ describe ( 'prefix collision detection' , ( ) => {
129+ beforeEach ( async ( ) => {
130+ // Clear S3 bucket before each test
131+ await clearTestBucket ( )
132+ // Clear database records before each test
133+ await payload . delete ( {
134+ collection : mediaWithPrefixSlug ,
135+ where : { } ,
136+ } )
137+ await payload . delete ( {
138+ collection : mediaSlug ,
139+ where : { } ,
140+ } )
141+ } )
142+
143+ it ( 'detects collision within same prefix' , async ( ) => {
144+ const imageFile = path . resolve ( dirname , '../uploads/image.png' )
145+
146+ // Upload twice with same prefix
147+ const upload1 = await payload . create ( {
148+ collection : mediaWithPrefixSlug ,
149+ data : { } ,
150+ filePath : imageFile ,
151+ } )
152+
153+ const upload2 = await payload . create ( {
154+ collection : mediaWithPrefixSlug ,
155+ data : { } ,
156+ filePath : imageFile ,
157+ } )
158+
159+ expect ( upload1 . filename ) . toBe ( 'image.png' )
160+ expect ( upload2 . filename ) . toBe ( 'image-1.png' )
161+ expect ( upload1 . prefix ) . toBe ( prefix )
162+ expect ( upload2 . prefix ) . toBe ( prefix )
163+ } )
164+
165+ it ( 'works normally for collections without prefix' , async ( ) => {
166+ const imageFile = path . resolve ( dirname , '../uploads/image.png' )
167+
168+ // Upload twice to collection without prefix
169+ const upload1 = await payload . create ( {
170+ collection : mediaSlug ,
171+ data : { } ,
172+ filePath : imageFile ,
173+ } )
174+
175+ const upload2 = await payload . create ( {
176+ collection : mediaSlug ,
177+ data : { } ,
178+ filePath : imageFile ,
179+ } )
180+
181+ expect ( upload1 . filename ) . toBe ( 'image.png' )
182+ expect ( upload2 . filename ) . toBe ( 'image-1.png' )
183+ // @ts -expect-error prefix should never be set
184+ expect ( upload1 . prefix ) . toBeUndefined ( )
185+ // @ts -expect-error prefix should never be set
186+ expect ( upload2 . prefix ) . toBeUndefined ( )
187+ } )
188+
189+ it ( 'allows same filename under different prefixes' , async ( ) => {
190+ const imageFile = path . resolve ( dirname , '../uploads/image.png' )
191+
192+ // Upload with default prefix from config ('test-prefix')
193+ const upload1 = await payload . create ( {
194+ collection : mediaWithPrefixSlug ,
195+ data : { } ,
196+ filePath : imageFile ,
197+ } )
198+
199+ // Upload with different prefix
200+ const upload2 = await payload . create ( {
201+ collection : mediaWithPrefixSlug ,
202+ data : {
203+ prefix : 'different-prefix' ,
204+ } ,
205+ filePath : imageFile ,
206+ } )
207+
208+ expect ( upload1 . filename ) . toBe ( 'image.png' )
209+ expect ( upload2 . filename ) . toBe ( 'image.png' ) // Should NOT increment
210+ expect ( upload1 . prefix ) . toBe ( prefix ) // 'test-prefix'
211+ expect ( upload2 . prefix ) . toBe ( 'different-prefix' )
212+ } )
213+
214+ it ( 'supports multi-tenant scenario with dynamic prefix from hook' , async ( ) => {
215+ const imageFile = path . resolve ( dirname , '../uploads/image.png' )
216+
217+ // Tenant A uploads logo.png
218+ const tenantAUpload = await payload . create ( {
219+ collection : mediaWithDynamicPrefixSlug ,
220+ data : { tenant : 'a' } ,
221+ filePath : imageFile ,
222+ } )
223+
224+ // Tenant B uploads logo.png
225+ const tenantBUpload = await payload . create ( {
226+ collection : mediaWithDynamicPrefixSlug ,
227+ data : { tenant : 'b' } ,
228+ filePath : imageFile ,
229+ } )
230+
231+ // Both should keep original filename
232+ expect ( tenantAUpload . filename ) . toBe ( 'image.png' )
233+ expect ( tenantBUpload . filename ) . toBe ( 'image.png' )
234+ expect ( tenantAUpload . prefix ) . toBe ( 'tenant-a' )
235+ expect ( tenantBUpload . prefix ) . toBe ( 'tenant-b' )
236+ } )
237+ } )
238+
122239 async function createTestBucket ( ) {
123240 try {
124241 const makeBucketRes = await client . send ( new AWS . CreateBucketCommand ( { Bucket : TEST_BUCKET } ) )
@@ -144,15 +261,11 @@ describe('@payloadcms/storage-s3', () => {
144261 return
145262 }
146263
147- const deleteParams = {
264+ const deleteParams : AWS . DeleteObjectsCommandInput = {
148265 Bucket : TEST_BUCKET ,
149- Delete : { Objects : [ ] } ,
266+ Delete : { Objects : listedObjects . Contents . map ( ( { Key } ) => ( { Key } ) ) } ,
150267 }
151268
152- listedObjects . Contents . forEach ( ( { Key } ) => {
153- deleteParams . Delete . Objects . push ( { Key } )
154- } )
155-
156269 const deleteResult = await client . send ( new AWS . DeleteObjectsCommand ( deleteParams ) )
157270 if ( deleteResult . Errors ?. length ) {
158271 throw new Error ( JSON . stringify ( deleteResult . Errors ) )
@@ -169,12 +282,12 @@ describe('@payloadcms/storage-s3', () => {
169282 uploadId : number | string
170283 } ) {
171284 const uploadData = ( await payload . findByID ( {
172- collection : collectionSlug ,
285+ collection : collectionSlug as CollectionSlug ,
173286 id : uploadId ,
174287 } ) ) as unknown as { filename : string ; sizes : Record < string , { filename : string } > }
175288
176289 const fileKeys = Object . keys ( uploadData . sizes || { } ) . map ( ( key ) => {
177- const rawFilename = uploadData . sizes [ key ] . filename
290+ const rawFilename = uploadData ? .sizes ?. [ key ] ? .filename
178291 return prefix ? `${ prefix } /${ rawFilename } ` : rawFilename
179292 } )
180293
0 commit comments