diff --git a/src/packages/StorageFileApi.ts b/src/packages/StorageFileApi.ts index 82f1bb1..d722247 100644 --- a/src/packages/StorageFileApi.ts +++ b/src/packages/StorageFileApi.ts @@ -1,4 +1,4 @@ -import { isStorageError, StorageError, StorageUnknownError } from '../lib/errors' +import { isStorageError, StorageError, StorageApiError, StorageUnknownError } from '../lib/errors' import { Fetch, get, head, post, put, remove } from '../lib/fetch' import { recursiveToCamel, resolveFetch } from '../lib/helpers' import { @@ -770,6 +770,203 @@ export default class StorageFileApi { } } + /** + * Purges the cache for a specific object from the CDN. + * Note: This method only works with individual file paths. + * Use purgeCacheByPrefix() to purge multiple objects or entire folders. + * + * @param path The specific file path to purge from cache. Cannot be empty or contain wildcards. + * @param parameters Optional fetch parameters like AbortController signal. + */ + async purgeCache( + path: string, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string; purgedPath: string } + error: null + } + | { + data: null + error: StorageError + } + > { + try { + // Validate input + if (!path || path.trim() === '') { + return { + data: null, + error: new StorageError( + 'Path is required for cache purging. Use purgeCacheByPrefix() to purge folders or entire buckets.' + ), + } + } + + // Check for wildcards + if (path.includes('*')) { + return { + data: null, + error: new StorageError( + 'Wildcard purging is not supported. Please specify an exact file path.' + ), + } + } + + const cleanPath = this._removeEmptyFolders(path) + const cdnPath = `${this.bucketId}/${cleanPath}` + + const data = await remove( + this.fetch, + `${this.url}/cdn/${cdnPath}`, + {}, + { headers: this.headers }, + parameters + ) + + return { + data: { + message: data?.message || 'success', + purgedPath: cleanPath, + }, + error: null, + } + } catch (error) { + if (isStorageError(error)) { + return { data: null, error } + } + + throw error + } + } + + /** + * Purges the cache for all objects in a folder or entire bucket. + * This method lists objects first, then purges each individually. + * + * @param prefix The folder prefix to purge (empty string for entire bucket) + * @param options Optional configuration for listing and purging + * @param parameters Optional fetch parameters + */ + async purgeCacheByPrefix( + prefix: string = '', + options?: { + limit?: number + batchSize?: number + }, + parameters?: FetchParameters + ): Promise< + | { + data: { message: string; purgedPaths: string[]; warnings?: string[] } + error: null + } + | { + data: null + error: StorageError + } + > { + try { + const batchSize = options?.batchSize || 100 + const purgedPaths: string[] = [] + const warnings: string[] = [] + + // List all objects with the given prefix + const { data: objects, error: listError } = await this.list(prefix, { + limit: options?.limit || 1000, + offset: 0, + sortBy: { + column: 'name', + order: 'asc', + }, + }) + + if (listError) { + return { data: null, error: listError } + } + + if (!objects || objects.length === 0) { + return { + data: { + message: 'No objects found to purge', + purgedPaths: [], + }, + error: null, + } + } + + // Extract file paths and filter out folders + const filePaths = objects + .filter((obj) => obj.name && !obj.name.endsWith('/')) // Only files, not folders + .map((obj) => (prefix ? `${prefix}/${obj.name}` : obj.name)) + + if (filePaths.length === 0) { + return { + data: { + message: 'No files found to purge (only folders detected)', + purgedPaths: [], + }, + error: null, + } + } + + // Process files in batches to avoid overwhelming the API + for (let i = 0; i < filePaths.length; i += batchSize) { + const batch = filePaths.slice(i, i + batchSize) + + for (const filePath of batch) { + try { + const { error: purgeError } = await this.purgeCache(filePath, parameters) + + if (purgeError) { + warnings.push(`Failed to purge ${filePath}: ${purgeError.message}`) + } else { + purgedPaths.push(filePath) + } + } catch (error) { + warnings.push(`Failed to purge ${filePath}: ${(error as Error).message}`) + } + } + } + + // If all paths failed, return error + if (purgedPaths.length === 0 && warnings.length > 0) { + return { + data: null, + error: new StorageError( + `All purge operations failed: ${warnings.slice(0, 3).join(', ')}${ + warnings.length > 3 ? '...' : '' + }` + ), + } + } + + const message = + purgedPaths.length > 0 + ? `Successfully purged ${purgedPaths.length} object(s)${ + warnings.length > 0 ? ` (${warnings.length} failed)` : '' + }` + : 'No objects were purged' + + const result: { message: string; purgedPaths: string[]; warnings?: string[] } = { + message, + purgedPaths, + } + + if (warnings.length > 0) { + result.warnings = warnings + } + + return { + data: result, + error: null, + } + } catch (error) { + if (isStorageError(error)) { + return { data: null, error } + } + throw error + } + } + protected encodeMetadata(metadata: Record) { return JSON.stringify(metadata) } diff --git a/test/storageFileApi.test.ts b/test/storageFileApi.test.ts index 2db8ca5..04b7c38 100644 --- a/test/storageFileApi.test.ts +++ b/test/storageFileApi.test.ts @@ -576,58 +576,476 @@ describe('Object API', () => { 'height:200,width:200,resizing_type:fill,quality:60' ) }) -}) -describe('error handling', () => { - let mockError: Error + describe('Purge Cache - Mock Tests', () => { + beforeEach(() => { + jest.resetAllMocks() + }) - beforeEach(() => { - mockError = new Error('Network failure') - }) + afterEach(() => { + jest.restoreAllMocks() + }) - afterEach(() => { - jest.restoreAllMocks() - }) + test('purge cache - single file success', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' - it('throws unknown errors', async () => { - global.fetch = jest.fn().mockImplementation(() => Promise.reject(mockError)) - const storage = new StorageClient('http://localhost:8000/storage/v1', { - apikey: 'test-token', + const res = await mockStorage.from(testBucket).purgeCache('test-file.jpg') + expect(res.error).toBeNull() + expect(res.data?.message).toEqual('success') + expect(res.data?.purgedPath).toEqual('test-file.jpg') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn/${testBucket}/test-file.jpg`), + expect.objectContaining({ + method: 'DELETE', + }) + ) }) - const { data, error } = await storage.from('test').list() - expect(data).toBeNull() - expect(error).not.toBeNull() - expect(error?.message).toBe('Network failure') + test('purge cache - rejects empty path', async () => { + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCache('') + expect(res.data).toBeNull() + expect(res.error?.message).toContain('Path is required') + }) + + test('purge cache - rejects wildcard', async () => { + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCache('folder/*') + expect(res.data).toBeNull() + expect(res.error?.message).toContain('Wildcard purging is not supported') + }) + + test('purge cache - with path normalization', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCache('/folder//file.jpg/') + expect(res.error).toBeNull() + expect(res.data?.purgedPath).toEqual('folder/file.jpg') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn/${testBucket}/folder/file.jpg`), + expect.objectContaining({ + method: 'DELETE', + }) + ) + }) + + test('purge cache - handles 404 error', async () => { + const mockResponse = new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + } + ) + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCache('nonexistent.jpg') + expect(res.data).toBeNull() + expect(res.error).not.toBeNull() + expect(res.error?.message).toContain('Object not found') + }) + + test('purge cache - with AbortController', async () => { + const mockResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + global.fetch = jest.fn().mockResolvedValue(mockResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + const abortController = new AbortController() + + const res = await mockStorage + .from(testBucket) + .purgeCache('test.png', { signal: abortController.signal }) + expect(res.error).toBeNull() + expect(res.data?.message).toEqual('success') + + expect(global.fetch).toHaveBeenCalledWith( + expect.stringContaining(`/cdn/${testBucket}/test.png`), + expect.objectContaining({ + method: 'DELETE', + signal: abortController.signal, + }) + ) + }) }) - it('handles malformed responses', async () => { - const mockResponse = new Response(JSON.stringify({ message: 'Internal server error' }), { - status: 500, - statusText: 'Internal Server Error', + describe('Purge Cache By Prefix - Mock Tests', () => { + beforeEach(() => { + jest.resetAllMocks() + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('purge cache by prefix - successful folder purge', async () => { + // Mock list response + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + // Mock purge responses for each file + const purgeResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + let fetchCallCount = 0 + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++ + // First call returns list response + if (fetchCallCount === 1) return Promise.resolve(listResponse) + // Subsequent calls return purge responses + return Promise.resolve(purgeResponse.clone()) + }) + + const mockStorage = new StorageClient(URL, { apikey: KEY }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCacheByPrefix('folder') + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(2) + expect(res.data?.purgedPaths).toEqual(['folder/file1.jpg', 'folder/file2.png']) + expect(res.data?.message).toContain('Successfully purged 2 object(s)') }) - global.fetch = jest.fn().mockImplementation(() => Promise.resolve(mockResponse)) - const storage = new StorageClient('http://localhost:8000/storage/v1', { - apikey: 'test-token', + test('purge cache by prefix - empty folder', async () => { + const listResponse = new Response(JSON.stringify([]), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + global.fetch = jest.fn().mockResolvedValue(listResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCacheByPrefix('empty-folder') + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(0) + expect(res.data?.message).toEqual('No objects found to purge') }) - const { data, error } = await storage.from('test').list() - expect(data).toBeNull() - expect(error).toBeInstanceOf(StorageError) - expect(error?.message).toBe('Internal server error') + test('purge cache by prefix - handles partial failures', async () => { + let fetchCallCount = 0 + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++ + if (fetchCallCount === 1) { + // List response + return Promise.resolve( + new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + { name: 'file3.gif', id: '3' }, + ]), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ) + } + // Handle individual purge calls + const fileIndex = fetchCallCount - 2 + if (fileIndex === 1) { + // Second file fails + return Promise.resolve( + new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { status: 404 } + ) + ) + } + return Promise.resolve( + new Response(JSON.stringify({ message: 'success' }), { status: 200 }) + ) + }) + + const mockStorage = new StorageClient(URL, { apikey: KEY }) + const res = await mockStorage.from(bucketName).purgeCacheByPrefix('folder') + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(2) + expect(res.data?.purgedPaths).toEqual(['folder/file1.jpg', 'folder/file3.gif']) + expect(res.data?.warnings).toHaveLength(1) + expect(res.data?.warnings?.[0]).toContain('Failed to purge folder/file2.png') + expect(res.data?.message).toContain('Successfully purged 2 object(s) (1 failed)') + }) + + test('purge cache by prefix - all failures', async () => { + // Mock list response + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + const errorResponse = new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) // List call + .mockResolvedValue(errorResponse) // All purge calls fail + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCacheByPrefix('folder') + expect(res.data).toBeNull() + expect(res.error).not.toBeNull() + expect(res.error?.message).toContain('All purge operations failed') + }) + + test('purge cache by prefix - filters out folders', async () => { + let fetchCallCount = 0 + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++ + if (fetchCallCount === 1) { + return Promise.resolve( + new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'subfolder/', id: '2' }, + { name: 'file2.png', id: '3' }, + ]), + { status: 200 } + ) + ) + } + return Promise.resolve( + new Response(JSON.stringify({ message: 'success' }), { status: 200 }) + ) + }) + + const mockStorage = new StorageClient(URL, { apikey: KEY }) + const res = await mockStorage.from(bucketName).purgeCacheByPrefix('folder') + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(2) + expect(res.data?.purgedPaths).toEqual(['folder/file1.jpg', 'folder/file2.png']) + expect(res.data?.message).toContain('Successfully purged 2 object(s)') + }) + + test('purge cache by prefix - only folders found', async () => { + // Mock list response with only folders + const listResponse = new Response( + JSON.stringify([ + { name: 'subfolder1/', id: '1' }, + { name: 'subfolder2/', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest.fn().mockResolvedValue(listResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCacheByPrefix('folder') + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(0) + expect(res.data?.message).toEqual('No files found to purge (only folders detected)') + }) + + test('purge cache by prefix - with batch size limit', async () => { + const fileCount = 150 + const files = Array.from({ length: fileCount }, (_, i) => ({ + name: `file${i}.jpg`, + id: String(i), + })) + + let fetchCallCount = 0 + global.fetch = jest.fn().mockImplementation(() => { + fetchCallCount++ + if (fetchCallCount === 1) { + return Promise.resolve(new Response(JSON.stringify(files), { status: 200 })) + } + return Promise.resolve( + new Response(JSON.stringify({ message: 'success' }), { status: 200 }) + ) + }) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const res = await mockStorage.from(bucketName).purgeCacheByPrefix('folder', { batchSize: 50 }) + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(150) + expect(res.data?.message).toContain('Successfully purged 150 object(s)') + }) + + test('purge cache by prefix - list error', async () => { + const listErrorResponse = new Response( + JSON.stringify({ + statusCode: '403', + error: 'Forbidden', + message: 'Access denied', + }), + { + status: 403, + statusText: 'Forbidden', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest.fn().mockResolvedValue(listErrorResponse) + + const mockStorage = new StorageClient(URL, { Authorization: `Bearer ${KEY}` }) + const testBucket = 'test-bucket' + + const res = await mockStorage.from(testBucket).purgeCacheByPrefix('folder') + expect(res.data).toBeNull() + expect(res.error).not.toBeNull() + expect(res.error?.message).toContain('Access denied') + }) + + test('purge cache by prefix - with custom options', async () => { + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + const purgeResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + // Track and respond to each fetch call + let fetchCalls = 0 + global.fetch = jest.fn().mockImplementation(() => { + fetchCalls++ + if (fetchCalls === 1) { + return Promise.resolve(listResponse) + } + return Promise.resolve(purgeResponse.clone()) // Clone for multiple uses + }) + + const mockStorage = new StorageClient(URL, { apikey: KEY }) + const testBucket = 'test-bucket' + const abortController = new AbortController() + + const res = await mockStorage + .from(testBucket) + .purgeCacheByPrefix( + 'folder', + { limit: 500, batchSize: 25 }, + { signal: abortController.signal } + ) + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(2) + expect(res.data?.message).toContain('Successfully purged 2 object(s)') + }) }) - it('handles network timeouts', async () => { - mockError = new Error('Network timeout') - global.fetch = jest.fn().mockImplementation(() => Promise.reject(mockError)) - const storage = new StorageClient('http://localhost:8000/storage/v1', { - apikey: 'test-token', + describe('Purge Cache - Integration Tests (Skipped)', () => { + test.skip('purge cache for specific object', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).purgeCache(uploadPath) + + expect(res.error).toBeNull() + expect(res.data?.message).toEqual('success') + expect(res.data?.purgedPath).toEqual(uploadPath) }) - const { data, error } = await storage.from('test').list() - expect(data).toBeNull() - expect(error).not.toBeNull() - expect(error?.message).toBe('Network timeout') + test.skip('purge cache by prefix for folder', async () => { + const file1Path = `testfolder/file1-${Date.now()}.jpg` + const file2Path = `testfolder/file2-${Date.now()}.jpg` + + await storage.from(bucketName).upload(file1Path, file) + await storage.from(bucketName).upload(file2Path, file) + + const res = await storage.from(bucketName).purgeCacheByPrefix('testfolder') + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths).toHaveLength(2) + expect(res.data?.message).toContain('Successfully purged 2 object(s)') + }) + + test.skip('purge cache by prefix for entire bucket', async () => { + await storage.from(bucketName).upload(uploadPath, file) + const res = await storage.from(bucketName).purgeCacheByPrefix('') + + expect(res.error).toBeNull() + expect(res.data?.purgedPaths.length).toBeGreaterThan(0) + expect(res.data?.message).toContain('Successfully purged') + }) }) }) diff --git a/test/storageFileApiErrorHandling.test.ts b/test/storageFileApiErrorHandling.test.ts index 53f694d..7842cfa 100644 --- a/test/storageFileApiErrorHandling.test.ts +++ b/test/storageFileApiErrorHandling.test.ts @@ -1,6 +1,5 @@ import { StorageClient } from '../src/index' import { StorageError, StorageUnknownError } from '../src/lib/errors' - // Mock URL and credentials for testing const URL = 'http://localhost:8000/storage/v1' const KEY = 'test-api-key' @@ -314,4 +313,363 @@ describe('File API Error Handling', () => { mockFn.mockRestore() }) }) + + describe('purgeCache', () => { + it('wraps non-Response errors as StorageUnknownError', async () => { + const nonResponseError = new TypeError('Invalid copy operation') + global.fetch = jest.fn().mockImplementation(() => Promise.reject(nonResponseError)) + const storage = new StorageClient(URL, { apikey: KEY }) + const { data, error } = await storage.from(BUCKET_ID).purgeCache('test.png') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toBe('Invalid copy operation') + }) + + it('rejects empty paths', async () => { + const storage = new StorageClient(URL, { apikey: KEY }) + const { data, error } = await storage.from(BUCKET_ID).purgeCache('') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toBe( + 'Path is required for cache purging. Use purgeCacheByPrefix() to purge folders or entire buckets.' + ) + }) + + it('rejects wildcard paths', async () => { + const storage = new StorageClient(URL, { apikey: KEY }) + const { data, error } = await storage.from(BUCKET_ID).purgeCache('folder/*') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toBe( + 'Wildcard purging is not supported. Please specify an exact file path.' + ) + }) + + it('wraps non-Response errors as StorageUnknownError', async () => { + const nonResponseError = new TypeError('Invalid purge operation') + global.fetch = jest.fn().mockImplementation(() => Promise.reject(nonResponseError)) + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCache('test.png') + expect(data).toBeNull() + expect(error).toBeInstanceOf(StorageUnknownError) + expect(error?.message).toBe('Invalid purge operation') + }) + + it('throws non-StorageError exceptions', async () => { + const storage = new StorageClient(URL, { apikey: KEY }) + + const mockFn = jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + const error = new Error('Unexpected error in purgeCache') + Object.defineProperty(error, 'name', { value: 'CustomError' }) + throw error + }) + + await expect(storage.from(BUCKET_ID).purgeCache('test.png')).rejects.toThrow( + 'Unexpected error in purgeCache' + ) + + mockFn.mockRestore() + }) + }) + + describe('purgeCacheByPrefix', () => { + beforeEach(() => { + // Mock Response constructor globally + global.Response = (jest.fn().mockImplementation((body, init) => ({ + json: () => Promise.resolve(JSON.parse(body)), + status: init?.status || 200, + headers: new Map(Object.entries(init?.headers || {})), + ok: init?.status ? init.status >= 200 && init.status < 300 : true, + })) as unknown) as typeof Response + }) + + it('handles StorageError during list operation', async () => { + const mockResponse = { + ok: false, + status: 403, + json: () => + Promise.resolve({ + statusCode: '403', + error: 'Forbidden', + message: 'Access denied to list objects', + }), + headers: new Map([['Content-Type', 'application/json']]), + } + global.fetch = jest.fn().mockImplementation(() => Promise.resolve(mockResponse)) + const storage = new StorageClient(URL, { apikey: KEY }) + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toContain(':403') + }) + + it('handles mixed success and failure during purge operations', async () => { + // Mock successful list response + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + { name: 'file3.gif', id: '3' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + // Mock purge responses - some succeed, some fail + const successResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + const errorResponse = new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) // List succeeds + .mockResolvedValueOnce(successResponse) // First purge succeeds + .mockResolvedValueOnce(errorResponse) // Second purge fails + .mockResolvedValueOnce(successResponse) // Third purge succeeds + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(error).toBeNull() + expect(data?.purgedPaths).toHaveLength(2) + expect(data?.warnings).toHaveLength(1) + expect(data?.warnings?.[0]).toContain('Failed to purge folder/file2.png') + expect(data?.message).toContain('Successfully purged 2 object(s) (1 failed)') + }) + + it('handles all purge operations failing', async () => { + // Mock successful list response + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + const errorResponse = new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) // List succeeds + .mockResolvedValue(errorResponse) // All purge operations fail + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toContain('All purge operations failed') + }) + + it('handles non-StorageError exceptions during individual purge operations', async () => { + // Mock successful list response + const listResponse = new Response( + JSON.stringify([ + { name: 'file1.jpg', id: '1' }, + { name: 'file2.png', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + const successResponse = new Response(JSON.stringify({ message: 'success' }), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) // List succeeds + .mockResolvedValueOnce(successResponse) // First purge succeeds + .mockImplementationOnce(() => { + const error = new Error('Unexpected network error') + Object.defineProperty(error, 'name', { value: 'CustomError' }) + throw error + }) // Second purge throws non-StorageError + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(error).toBeNull() + expect(data?.purgedPaths).toHaveLength(1) + expect(data?.warnings).toHaveLength(1) + expect(data?.warnings?.[0]).toContain( + 'Failed to purge folder/file2.png: Unexpected network error' + ) + expect(data?.message).toContain('Successfully purged 1 object(s) (1 failed)') + }) + + it('throws non-StorageError exceptions at top level', async () => { + const storage = new StorageClient(URL, { apikey: KEY }) + + const mockFn = jest.spyOn(global, 'fetch').mockImplementationOnce(() => { + const error = new Error('Unexpected error in purgeCacheByPrefix') + Object.defineProperty(error, 'name', { value: 'CustomError' }) + throw error + }) + + await expect(storage.from(BUCKET_ID).purgeCacheByPrefix('folder')).rejects.toThrow( + 'Unexpected error in purgeCacheByPrefix' + ) + + mockFn.mockRestore() + }) + + it('handles empty list response', async () => { + const listResponse = new Response(JSON.stringify([]), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + global.fetch = jest.fn().mockResolvedValue(listResponse) + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('empty-folder') + expect(error).toBeNull() + expect(data?.purgedPaths).toHaveLength(0) + expect(data?.message).toEqual('No objects found to purge') + }) + + it('handles list response with only folders', async () => { + const listResponse = new Response( + JSON.stringify([ + { name: 'subfolder1/', id: '1' }, + { name: 'subfolder2/', id: '2' }, + ]), + { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest.fn().mockResolvedValue(listResponse) + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(error).toBeNull() + expect(data?.purgedPaths).toHaveLength(0) + expect(data?.message).toEqual('No files found to purge (only folders detected)') + }) + + it('wraps non-Response errors from list as StorageUnknownError', async () => { + const nonResponseError = new TypeError('Invalid list operation during purge') + global.fetch = jest.fn().mockImplementation(() => Promise.reject(nonResponseError)) + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(data).toBeNull() + expect(error).toBeInstanceOf(StorageUnknownError) + expect(error?.message).toBe('Invalid list operation during purge') + }) + + it('handles partial success with many warnings', async () => { + // Create many files to test warning truncation + const files = Array.from({ length: 10 }, (_, i) => ({ name: `file${i}.jpg`, id: String(i) })) + const listResponse = new Response(JSON.stringify(files), { + status: 200, + statusText: 'OK', + headers: { 'Content-Type': 'application/json' }, + }) + + const errorResponse = new Response( + JSON.stringify({ + statusCode: '404', + error: 'Not Found', + message: 'Object not found', + }), + { + status: 404, + statusText: 'Not Found', + headers: { 'Content-Type': 'application/json' }, + } + ) + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) // List succeeds + .mockResolvedValue(errorResponse) // All purge operations fail + + const storage = new StorageClient(URL, { apikey: KEY }) + + const { data, error } = await storage.from(BUCKET_ID).purgeCacheByPrefix('folder') + expect(data).toBeNull() + expect(error).not.toBeNull() + expect(error?.message).toContain('All purge operations failed') + // Should truncate the error message to avoid extremely long messages + expect(error?.message.length).toBeLessThan(1000) + }) + + it('handles AbortController signal properly', async () => { + const listResponse = { + ok: true, + status: 200, + json: () => Promise.resolve([{ name: 'file1.jpg', id: '1' }]), + } + + const purgeResponse = { + ok: true, + status: 200, + json: () => Promise.resolve({ message: 'success' }), + } + + global.fetch = jest + .fn() + .mockResolvedValueOnce(listResponse) + .mockResolvedValueOnce(purgeResponse) + + const storage = new StorageClient(URL, { apikey: KEY }) + const abortController = new AbortController() + + const { data, error } = await storage + .from(BUCKET_ID) + .purgeCacheByPrefix('folder', undefined, { signal: abortController.signal }) + expect(error).toBeNull() + expect(data?.purgedPaths).toHaveLength(1) + expect(data?.purgedPaths).toEqual(['folder/file1.jpg']) + expect(data?.message).toContain('Successfully purged 1 object(s)') + }) + }) })