diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts index 8b385f86b5..699ac5f19f 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/copy.spec.ts @@ -1,8 +1,7 @@ -import * as StorageModule from '../../../storage-internal'; - +import { copy, CopyInput } from '../../../storage-internal'; import { copyHandler, CopyHandlerInput } from '../copy'; -const copySpy = jest.spyOn(StorageModule, 'copy'); +jest.mock('../../../storage-internal'); const baseInput: CopyHandlerInput = { destinationPrefix: 'destination/', @@ -25,8 +24,14 @@ const baseInput: CopyHandlerInput = { }; describe('copyHandler', () => { + const mockCopy = jest.mocked(copy); + + beforeEach(() => { + mockCopy.mockResolvedValue({ path: '' }); + }); + afterEach(() => { - copySpy.mockClear(); + mockCopy.mockReset(); }); it('calls `copy` wth the expected values', () => { @@ -37,7 +42,7 @@ describe('copyHandler', () => { region: `${baseInput.config.region}`, }; - const expected: StorageModule.CopyInput = { + const expected: CopyInput = { destination: { expectedBucketOwner: baseInput.config.accountId, bucket, @@ -56,7 +61,7 @@ describe('copyHandler', () => { }, }; - expect(copySpy).toHaveBeenCalledWith(expected); + expect(mockCopy).toHaveBeenCalledWith(expected); }); it('provides eTag and notModifiedSince to copy for durableness', () => { @@ -67,7 +72,7 @@ describe('copyHandler', () => { region: `${baseInput.config.region}`, }; - const copyInput = copySpy.mock.lastCall?.[0]; + const copyInput = mockCopy.mock.lastCall?.[0]; expect(copyInput).toHaveProperty('source', { expectedBucketOwner: `${baseInput.config.accountId}`, bucket, @@ -100,6 +105,23 @@ describe('copyHandler', () => { }), }); - expect(copySpy).toHaveBeenCalledWith(expected); + expect(mockCopy).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = copyHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE' }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockCopy.mockRejectedValue(new Error(errorMessage)); + const { result } = copyHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts index 04fb557ab4..e7c83f2ff3 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/createFolder.spec.ts @@ -1,8 +1,8 @@ import { createFolderHandler, CreateFolderHandlerInput } from '../createFolder'; -import * as InternalStorageModule from '../../../storage-internal'; +import { uploadData, UploadDataInput } from '../../../storage-internal'; -const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData'); +jest.mock('../../../storage-internal'); const credentials = jest.fn(); @@ -22,22 +22,28 @@ const baseInput: CreateFolderHandlerInput = { destinationPrefix: 'prefix/', }; -const error = new Error('Failed!'); - describe('createFolderHandler', () => { + const mockUploadDataReturnValue = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + result: Promise.resolve({ path: '' }), + state: 'SUCCESS' as const, + }; + const mockUploadData = jest.mocked(uploadData); + beforeEach(() => { jest.clearAllMocks(); + mockUploadData.mockReturnValue(mockUploadDataReturnValue); }); - it('behaves as expected in the happy path', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }); + afterEach(() => { + mockUploadData.mockReset(); + }); + beforeEach(() => {}); + + it('behaves as expected in the happy path', async () => { const { result } = createFolderHandler(baseInput); expect(await result).toStrictEqual({ status: 'COMPLETE' }); @@ -46,7 +52,7 @@ describe('createFolderHandler', () => { it('calls `uploadData` with the expected values', () => { createFolderHandler({ ...baseInput, options: { preventOverwrite: true } }); - const expected: InternalStorageModule.UploadDataInput = { + const expected: UploadDataInput = { data: '', options: { expectedBucketOwner: config.accountId, @@ -62,21 +68,14 @@ describe('createFolderHandler', () => { path: `${baseInput.destinationPrefix}${baseInput.data.key}`, }; - expect(uploadDataSpy).toHaveBeenCalledWith(expected); + expect(mockUploadData).toHaveBeenCalledWith(expected); }); it('calls provided onProgress callback as expected in the happy path', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ totalBytes: 23, transferredBytes: 23 }); - - return { - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ totalBytes: 23, transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = createFolderHandler({ @@ -91,17 +90,10 @@ describe('createFolderHandler', () => { }); it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ transferredBytes: 23 }); - - return { - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.resolve({ path: '' }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = createFolderHandler({ @@ -116,18 +108,18 @@ describe('createFolderHandler', () => { }); it('handles a failure as expected', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), - result: Promise.reject(error), + const errorMessage = 'error-message'; + + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, + result: Promise.reject(new Error(errorMessage)), state: 'ERROR', }); const { result } = createFolderHandler(baseInput); expect(await result).toStrictEqual({ - message: error.message, + message: errorMessage, status: 'FAILED', }); }); @@ -137,10 +129,8 @@ describe('createFolderHandler', () => { const overwritePreventedError = new Error(message); overwritePreventedError.name = 'PreconditionFailed'; - uploadDataSpy.mockReturnValueOnce({ - cancel: jest.fn(), - pause: jest.fn(), - resume: jest.fn(), + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(overwritePreventedError), state: 'ERROR', }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts index 7e99f1cc3f..82ad887b76 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/delete.spec.ts @@ -1,8 +1,8 @@ -import * as StorageModule from '../../../storage-internal'; +import { remove, RemoveInput } from '../../../storage-internal'; import { deleteHandler, DeleteHandlerInput } from '../delete'; -const removeSpy = jest.spyOn(StorageModule, 'remove'); +jest.mock('../../../storage-internal'); const baseInput: DeleteHandlerInput = { config: { @@ -23,10 +23,20 @@ const baseInput: DeleteHandlerInput = { }; describe('deleteHandler', () => { + const mockRemove = jest.mocked(remove); + + beforeEach(() => { + mockRemove.mockResolvedValue({ path: '' }); + }); + + afterEach(() => { + mockRemove.mockReset(); + }); + it('calls `remove` and returns the expected `key`', () => { deleteHandler(baseInput); - const expected: StorageModule.RemoveInput = { + const expected: RemoveInput = { path: baseInput.data.key, options: { expectedBucketOwner: baseInput.config.accountId, @@ -39,6 +49,23 @@ describe('deleteHandler', () => { }, }; - expect(removeSpy).toHaveBeenCalledWith(expected); + expect(mockRemove).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = deleteHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE' }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockRemove.mockRejectedValue(new Error(errorMessage)); + const { result } = deleteHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts index cf602feaf3..046b68a5b3 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/download.spec.ts @@ -1,8 +1,8 @@ -import * as StorageModule from '../../../storage-internal'; +import { getUrl, GetUrlInput } from '../../../storage-internal'; import { downloadHandler, DownloadHandlerInput } from '../download'; -const downloadSpy = jest.spyOn(StorageModule, 'getUrl'); +jest.mock('../../../storage-internal'); const baseInput: DownloadHandlerInput = { config: { @@ -23,10 +23,23 @@ const baseInput: DownloadHandlerInput = { }; describe('downloadHandler', () => { + const url = new URL('mock://fake.url'); + const mockGetUrl = jest.mocked(getUrl); + + beforeEach(() => { + const expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + 1); + mockGetUrl.mockResolvedValue({ expiresAt, url }); + }); + + afterEach(() => { + mockGetUrl.mockReset(); + }); + it('calls `getUrl` with the expected values', () => { downloadHandler(baseInput); - const expected: StorageModule.GetUrlInput = { + const expected: GetUrlInput = { path: baseInput.data.key, options: { bucket: { @@ -41,6 +54,23 @@ describe('downloadHandler', () => { }, }; - expect(downloadSpy).toHaveBeenCalledWith(expected); + expect(mockGetUrl).toHaveBeenCalledWith(expected); + }); + + it('returns a complete status', async () => { + const { result } = downloadHandler(baseInput); + + expect(await result).toEqual({ status: 'COMPLETE' }); + }); + + it('returns failed status', async () => { + const errorMessage = 'error-message'; + mockGetUrl.mockRejectedValue(new Error(errorMessage)); + const { result } = downloadHandler(baseInput); + + expect(await result).toEqual({ + status: 'FAILED', + message: errorMessage, + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts index 84f51da6e8..7689e27319 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/listLocationItems.spec.ts @@ -1,4 +1,4 @@ -import * as StorageModule from '../../../storage-internal'; +import { list } from '../../../storage-internal'; import { listLocationItemsHandler, @@ -16,9 +16,7 @@ Object.defineProperty(globalThis, 'crypto', { }, }); -const listSpy = jest - .spyOn(StorageModule, 'list') - .mockImplementation(() => Promise.resolve({ items: [], nextToken: '' })); +jest.mock('../../../storage-internal'); const baseInput: ListLocationItemsHandlerInput = { prefix: 'prefix/', @@ -34,12 +32,18 @@ const baseInput: ListLocationItemsHandlerInput = { const prefix = 'prefix1/'; describe('listLocationItemsHandler', () => { + const mockList = jest.mocked(list); + beforeEach(() => { - listSpy.mockClear(); + mockList.mockResolvedValue({ items: [], nextToken: '' }); + }); + + afterEach(() => { + mockList.mockReset(); }); it('returns the expected output shape in the happy path', async () => { - listSpy.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' }); + mockList.mockResolvedValueOnce({ items: [], nextToken: 'tokeno' }); const { items, nextToken } = await listLocationItemsHandler(baseInput); @@ -48,7 +52,7 @@ describe('listLocationItemsHandler', () => { }); it('provides expected `pageSize` to `list` on initial load', async () => { - listSpy.mockResolvedValueOnce({ items: [] }); + mockList.mockResolvedValueOnce({ items: [] }); const input = { ...baseInput, @@ -58,8 +62,8 @@ describe('listLocationItemsHandler', () => { await listLocationItemsHandler(input); - expect(listSpy).toHaveBeenCalledTimes(1); - expect(listSpy).toHaveBeenCalledWith({ + expect(mockList).toHaveBeenCalledTimes(1); + expect(mockList).toHaveBeenCalledWith({ path: input.prefix, options: { bucket: { @@ -75,8 +79,9 @@ describe('listLocationItemsHandler', () => { }, }); }); + it('provides `pageSize` number of items after removing items that match / or . or ..', async () => { - listSpy + mockList .mockResolvedValueOnce({ items: [ { path: `/`, lastModified: new Date(), size: 0 }, @@ -102,7 +107,48 @@ describe('listLocationItemsHandler', () => { const listItems = await listLocationItemsHandler(input); expect(listItems.items).toHaveLength(input.options.pageSize); - expect(listSpy).toHaveBeenCalledTimes(2); + expect(mockList).toHaveBeenCalledTimes(2); + }); + + it('can exclude by type', async () => { + mockList.mockResolvedValueOnce({ + items: [ + { path: `someFolder/`, lastModified: new Date(), size: 0 }, + { path: `someFile`, lastModified: new Date(), size: 56984 }, + ], + }); + + const input = { ...baseInput, options: { exclude: 'FOLDER' as const } }; + + const listItems = await listLocationItemsHandler(input); + expect(listItems.items).toHaveLength(1); + }); + + it('uses appropriate subpathStrategy when delimiter is present', async () => { + const input = { ...baseInput, options: { delimiter: '/' } }; + + await listLocationItemsHandler(input); + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ + subpathStrategy: { delimiter: '/', strategy: 'exclude' }, + }), + }) + ); + }); + + it('can list with an offset', async () => { + const input = { + ...baseInput, + options: { nextToken: 'some-token', pageSize: 3 }, + }; + + await listLocationItemsHandler(input); + expect(mockList).toHaveBeenCalledWith( + expect.objectContaining({ + options: expect.objectContaining({ pageSize: 3 }), + }) + ); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts index 2e123f8dae..8d1692d84e 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/upload.spec.ts @@ -1,5 +1,5 @@ -import * as InternalStorageModule from '../../../storage-internal'; -import * as StorageModule from 'aws-amplify/storage'; +import { uploadData, UploadDataInput } from '../../../storage-internal'; +import { isCancelError } from 'aws-amplify/storage'; import { MULTIPART_UPLOAD_THRESHOLD_BYTES, @@ -8,8 +8,8 @@ import { UNDEFINED_CALLBACKS, } from '../upload'; -const isCancelErrorSpy = jest.spyOn(StorageModule, 'isCancelError'); -const uploadDataSpy = jest.spyOn(InternalStorageModule, 'uploadData'); +jest.mock('aws-amplify/storage'); +jest.mock('../../../storage-internal'); const credentials = jest.fn(); @@ -23,34 +23,37 @@ const config: UploadHandlerInput['config'] = { const file = new File([], 'test-o'); -const onProgress = jest.fn(); - const baseInput: UploadHandlerInput = { config, data: { key: file.name, id: 'an-id', file }, destinationPrefix: 'prefix/', }; -const cancel = jest.fn(); -const pause = jest.fn(); -const resume = jest.fn(); - const error = new Error('Failed!'); describe('uploadHandler', () => { + const mockUploadDataReturnValue = { + cancel: jest.fn(), + pause: jest.fn(), + resume: jest.fn(), + result: Promise.resolve({ path: file.name }), + state: 'SUCCESS' as const, + }; + const mockIsCancelError = jest.mocked(isCancelError); + const mockUploadData = jest.mocked(uploadData); + const mockOnProgress = jest.fn(); + beforeEach(() => { - jest.clearAllMocks(); + mockUploadData.mockReturnValue(mockUploadDataReturnValue); }); - it('behaves as expected in the happy path', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); + afterEach(() => { + mockOnProgress.mockClear(); + mockIsCancelError.mockReset(); + mockUploadData.mockReset(); + }); + it('behaves as expected in the happy path', async () => { const { result } = uploadHandler(baseInput); expect(await result).toStrictEqual({ status: 'COMPLETE' }); @@ -59,7 +62,7 @@ describe('uploadHandler', () => { it('calls upload with the expected values', () => { uploadHandler({ ...baseInput, options: { preventOverwrite: true } }); - const expected: InternalStorageModule.UploadDataInput = { + const expected: UploadDataInput = { data: file, options: { expectedBucketOwner: config.accountId, @@ -76,57 +79,43 @@ describe('uploadHandler', () => { path: `${baseInput.destinationPrefix}${baseInput.data.key}`, }; - expect(uploadDataSpy).toHaveBeenCalledWith(expected); + expect(mockUploadData).toHaveBeenCalledWith(expected); }); it('calls provided onProgress callback as expected in the happy path', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ totalBytes: 23, transferredBytes: 23 }); - - return { - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ totalBytes: 23, transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = uploadHandler({ ...baseInput, - options: { onProgress }, + options: { onProgress: mockOnProgress }, }); expect(await result).toStrictEqual({ status: 'COMPLETE' }); - expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data, 1); + expect(mockOnProgress).toHaveBeenCalledTimes(1); + expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, 1); }); it('calls provided onProgress callback as expected when `totalBytes` is `undefined`', async () => { - uploadDataSpy.mockImplementation(({ options }) => { - // @ts-expect-error - `options` is potentially `undefined` in the `uploadData` input interface - options.onProgress({ transferredBytes: 23 }); - - return { - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }; + mockUploadData.mockImplementation(({ options }) => { + options?.onProgress?.({ transferredBytes: 23 }); + + return mockUploadDataReturnValue; }); const { result } = uploadHandler({ ...baseInput, - options: { onProgress }, + options: { onProgress: mockOnProgress }, }); expect(await result).toStrictEqual({ status: 'COMPLETE' }); - expect(onProgress).toHaveBeenCalledTimes(1); - expect(onProgress).toHaveBeenCalledWith(baseInput.data, undefined); + expect(mockOnProgress).toHaveBeenCalledTimes(1); + expect(mockOnProgress).toHaveBeenCalledWith(baseInput.data, undefined); }); it('returns the expected callback values for a file size greater than 5 mb', async () => { @@ -135,14 +124,6 @@ describe('uploadHandler', () => { '😅' ); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); - const { result, ...callbacks } = uploadHandler({ ...baseInput, data: { key: bigFile.name, id: 'hi!', file: bigFile }, @@ -150,20 +131,16 @@ describe('uploadHandler', () => { expect(await result).toStrictEqual({ status: 'COMPLETE' }); - expect(callbacks).toStrictEqual({ cancel, pause, resume }); + expect(callbacks).toStrictEqual({ + cancel: expect.any(Function), + pause: expect.any(Function), + resume: expect.any(Function), + }); }); it('returns undefined callback values for a file size less than 5 mb', async () => { const smallFile = new File([], '😅'); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, - result: Promise.resolve({ path: file.name }), - state: 'SUCCESS', - }); - const { result, ...callbacks } = uploadHandler({ ...baseInput, data: { key: smallFile.name, id: 'ohh', file: smallFile }, @@ -175,10 +152,8 @@ describe('uploadHandler', () => { }); it('handles a failure as expected', async () => { - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(error), state: 'ERROR', }); @@ -194,11 +169,9 @@ describe('uploadHandler', () => { it('handles a cancel failure as expected', async () => { // turn off console.warn in test output jest.spyOn(console, 'warn').mockReturnValueOnce(); - isCancelErrorSpy.mockReturnValue(true); - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockIsCancelError.mockReturnValue(true); + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(error), state: 'ERROR', }); @@ -215,10 +188,8 @@ describe('uploadHandler', () => { const preconditionError = new Error('Failed!'); preconditionError.name = 'PreconditionFailed'; - uploadDataSpy.mockReturnValueOnce({ - cancel, - pause, - resume, + mockUploadData.mockReturnValue({ + ...mockUploadDataReturnValue, result: Promise.reject(preconditionError), state: 'ERROR', }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts index f9cec8851a..5bd1e33ffc 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/__tests__/utils.spec.ts @@ -5,136 +5,234 @@ import { shouldExcludeLocation, getFileKey, parseAccessGrantLocation, + getFilteredLocations, + isFileItem, + isFileDataItem, + createFileDataItemFromLocation, + createFileDataItem, } from '../utils'; -describe('parseLocationAccess', () => { +describe('utils', () => { const bucket = 'test-bucket'; const folderPrefix = 'test-prefix/'; - const filePath = 'some-file.jpeg2000'; - + const fileKey = 'some-file.jpeg2000'; const id = 'intentionally-static-test-id'; + beforeAll(() => { Object.defineProperty(globalThis, 'crypto', { value: { randomUUID: () => id }, }); }); - it('throws if provided an invalid location scope', () => { - const invalidLocation: AccessGrantLocation = { - scope: 'nope', - permission: 'READ', - type: 'BUCKET', - }; + describe('parseAccessGrantLocation', () => { + it('throws if provided an invalid location scope', () => { + const invalidLocation: AccessGrantLocation = { + scope: 'nope', + permission: 'READ', + type: 'BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid scope: nope' + ); + }); - expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( - 'Invalid scope: nope' - ); - }); + it('throws if provided an invalid location type', () => { + const invalidLocation: AccessGrantLocation = { + scope: 's3://yes', + permission: 'READ', + // @ts-expect-error intentional coercing to allow unhappy path test + type: 'NOT_BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid location type: NOT_BUCKET' + ); + }); - it('throws if provided an invalid location type', () => { - const invalidLocation: AccessGrantLocation = { - scope: 's3://yes', - permission: 'READ', - // @ts-expect-error intentional coercing to allow unhappy path test - type: 'NOT_BUCKET', - }; + it('throws if provided an invalid location permission', () => { + const invalidLocation: AccessGrantLocation = { + scope: `s3://${bucket}/*`, + // @ts-expect-error force unhandled permission + permission: 'INVALID', + type: 'BUCKET', + }; + + expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( + 'Invalid location permission' + ); + }); - expect(() => parseAccessGrantLocation(invalidLocation)).toThrow( - 'Invalid location type: NOT_BUCKET' - ); + it('parses a BUCKET location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/*`, + type: 'BUCKET', + }; + const expected: LocationData = { + bucket, + id, + prefix: '', + permissions: ['delete', 'write'], + type: 'BUCKET', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); + + it('parses a PREFIX location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}*`, + type: 'PREFIX', + }; + + const expected: LocationData = { + bucket, + id, + prefix: folderPrefix, + permissions: ['delete', 'write'], + type: 'PREFIX', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); + + it('parses an OBJECT location as expected', () => { + const location: AccessGrantLocation = { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}${fileKey}`, + type: 'OBJECT', + }; + + const expected: LocationData = { + bucket, + id, + prefix: `${folderPrefix}${fileKey}`, + permissions: ['delete', 'write'], + type: 'OBJECT', + }; + + expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + }); }); - it('parses a BUCKET location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/*`, - type: 'BUCKET', - }; - const expected: LocationData = { - bucket, - id, - prefix: '', - permissions: ['delete', 'write'], - type: 'BUCKET', - }; + describe('getFileKey', () => { + it('should return the filename without the path', () => { + expect(getFileKey('/path/to/file.txt')).toBe('file.txt'); + expect(getFileKey('document.pdf')).toBe('document.pdf'); + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); + it('should handle paths with multiple slashes', () => { + expect(getFileKey('/path//to///file.txt')).toBe('file.txt'); + }); }); - it('parses a PREFIX location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}*`, + describe('shouldExcludeLocation', () => { + const location: LocationData = { + bucket: 'bucket', + id: 'id', + permissions: ['list', 'get'], + prefix: 'prefix/', type: 'PREFIX', }; - const expected: LocationData = { - bucket, - id, - prefix: folderPrefix, - permissions: ['delete', 'write'], - type: 'PREFIX', - }; + it('returns true when the provided location permissions match excluded permissions', () => { + const output = shouldExcludeLocation(location, { + exactPermissions: ['list', 'get'], + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); - }); + expect(output).toBe(true); + }); - it('parses an OBJECT location as expected', () => { - const location: AccessGrantLocation = { - permission: 'WRITE', - scope: `s3://${bucket}/${folderPrefix}${filePath}`, - type: 'OBJECT', - }; + it('returns true when the provided location type match excluded type', () => { + const output = shouldExcludeLocation(location, { type: 'PREFIX' }); - const expected: LocationData = { - bucket, - id, - prefix: `${folderPrefix}${filePath}`, - permissions: ['delete', 'write'], - type: 'OBJECT', - }; + expect(output).toBe(true); + }); - expect(parseAccessGrantLocation(location)).toStrictEqual(expected); - }); -}); + it('returns true when the provided location type is included in excluded types', () => { + const output = shouldExcludeLocation(location, { type: ['PREFIX'] }); -describe('getFileKey', () => { - it('should return the filename without the path', () => { - expect(getFileKey('/path/to/file.txt')).toBe('file.txt'); - expect(getFileKey('document.pdf')).toBe('document.pdf'); - }); + expect(output).toBe(true); + }); - it('should handle paths with multiple slashes', () => { - expect(getFileKey('/path//to///file.txt')).toBe('file.txt'); - }); -}); + it('returns false when provided a location without an exclude value', () => { + const output = shouldExcludeLocation(location); -describe('shouldExcludeLocation', () => { - const location: LocationData = { - bucket: 'bucket', - id: 'id', - permissions: ['list', 'get'], - prefix: 'prefix/', - type: 'PREFIX', - }; + expect(output).toBe(false); + }); + }); - it('returns true when the provided location permissions match excluded permissions', () => { - const output = shouldExcludeLocation(location, { - exactPermissions: ['list', 'get'], + describe('getFilteredLocations', () => { + it('should filter out non-folder-like prefix locations', () => { + const locations: AccessGrantLocation[] = [ + { + permission: 'WRITE', + scope: `s3://${bucket}/${folderPrefix}*`, + type: 'PREFIX', + }, + { + permission: 'WRITE', + scope: `s3://${bucket}/non-folder-like-prefix*`, + type: 'PREFIX', + }, + ]; + + expect(getFilteredLocations(locations)).toStrictEqual([ + expect.objectContaining({ prefix: folderPrefix }), + ]); }); + }); - expect(output).toBe(true); + describe('createFileDataItem', () => { + it('creates a FileDataItem from FileData', () => { + expect( + createFileDataItem({ + key: `prefix/${fileKey}`, + lastModified: new Date(1), + id, + size: 0, + type: 'FILE' as const, + }) + ).toStrictEqual(expect.objectContaining({ fileKey })); + }); }); - it('returns true when the provided location type match excluded type', () => { - const output = shouldExcludeLocation(location, { type: 'PREFIX' }); + describe('createFileDataItemFromLocation', () => { + const location: LocationData = { + bucket: 'bucket', + id: 'id', + permissions: ['list', 'get'], + prefix: `prefix/${fileKey}`, + type: 'OBJECT', + }; - expect(output).toBe(true); + it('creates a FileDataItem from location', () => { + expect(createFileDataItemFromLocation(location)).toStrictEqual( + expect.objectContaining({ + id: location.id, + type: 'FILE', + key: location.prefix, + fileKey, + lastModified: expect.any(Date), + size: 0, + }) + ); + }); }); - it('returns false when provided a location without an exclude value', () => { - const output = shouldExcludeLocation(location); + describe('isFileItem', () => { + it('should return true if object is FileItem', () => { + expect(isFileItem({ file: {} })).toBe(true); + expect(isFileItem({})).toBe(false); + }); + }); - expect(output).toBe(false); + describe('isFileDataItem', () => { + it('should return true if object is FileDataItem', () => { + expect(isFileDataItem({ fileKey: 'file-key' })).toBe(true); + expect(isFileDataItem({})).toBe(false); + }); }); }); diff --git a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts index 2fe3c8f38d..29608d2fc2 100644 --- a/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts +++ b/packages/react-storage/src/components/StorageBrowser/actions/handlers/listLocationItems.ts @@ -116,9 +116,9 @@ export const listLocationItemsHandler: ListLocationItemsHandler = async ( strategy: delimiter ? 'exclude' : 'include', }; - // `ListObjectsV2` returns the root `key` on initial request, which is from - // filtered from `results` by `parseResult`, creatimg a scenario where the - // return count of `results` to be one item less than provided the `pageSize`. + // `ListObjectsV2` returns the root `key` on initial request which, when from + // filtered from `results` by `parseResult`, creates a scenario where the + // return count of `results` is one item less than the provided `pageSize`. // To mitigate, if a `pageSize` is provided and there are no previous `results` // or `refresh` is `true` increment the provided `pageSize` by `1` const hasOffset = !nextToken;