@fluxbase/sdk-react 2026.1.22 → 2026.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,549 @@
1
+ /**
2
+ * Tests for storage hooks
3
+ */
4
+
5
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
6
+ import { renderHook, waitFor, act } from '@testing-library/react';
7
+ import {
8
+ useStorageList,
9
+ useStorageUpload,
10
+ useStorageUploadWithProgress,
11
+ useStorageDownload,
12
+ useStorageDelete,
13
+ useStoragePublicUrl,
14
+ useStorageTransformUrl,
15
+ useStorageSignedUrl,
16
+ useStorageSignedUrlWithOptions,
17
+ useStorageMove,
18
+ useStorageCopy,
19
+ useStorageBuckets,
20
+ useCreateBucket,
21
+ useDeleteBucket,
22
+ } from './use-storage';
23
+ import { createMockClient, createWrapper, createTestQueryClient } from './test-utils';
24
+
25
+ describe('useStorageList', () => {
26
+ it('should list files in bucket', async () => {
27
+ const mockFiles = [{ name: 'file1.txt' }, { name: 'file2.txt' }];
28
+ const listMock = vi.fn().mockResolvedValue({ data: mockFiles, error: null });
29
+ const fromMock = vi.fn().mockReturnValue({ list: listMock });
30
+
31
+ const client = createMockClient({
32
+ storage: { from: fromMock },
33
+ } as any);
34
+
35
+ const { result } = renderHook(
36
+ () => useStorageList('bucket'),
37
+ { wrapper: createWrapper(client) }
38
+ );
39
+
40
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
41
+ expect(result.current.data).toEqual(mockFiles);
42
+ expect(fromMock).toHaveBeenCalledWith('bucket');
43
+ });
44
+
45
+ it('should pass list options', async () => {
46
+ const listMock = vi.fn().mockResolvedValue({ data: [], error: null });
47
+ const fromMock = vi.fn().mockReturnValue({ list: listMock });
48
+
49
+ const client = createMockClient({
50
+ storage: { from: fromMock },
51
+ } as any);
52
+
53
+ renderHook(
54
+ () => useStorageList('bucket', { prefix: 'folder/', limit: 10, offset: 5 }),
55
+ { wrapper: createWrapper(client) }
56
+ );
57
+
58
+ await waitFor(() => {
59
+ expect(listMock).toHaveBeenCalledWith({ prefix: 'folder/', limit: 10, offset: 5 });
60
+ });
61
+ });
62
+
63
+ it('should throw error on list failure', async () => {
64
+ const error = new Error('List failed');
65
+ const listMock = vi.fn().mockResolvedValue({ data: null, error });
66
+ const fromMock = vi.fn().mockReturnValue({ list: listMock });
67
+
68
+ const client = createMockClient({
69
+ storage: { from: fromMock },
70
+ } as any);
71
+
72
+ const { result } = renderHook(
73
+ () => useStorageList('bucket'),
74
+ { wrapper: createWrapper(client) }
75
+ );
76
+
77
+ await waitFor(() => expect(result.current.isError).toBe(true));
78
+ expect(result.current.error).toBe(error);
79
+ });
80
+ });
81
+
82
+ describe('useStorageUpload', () => {
83
+ it('should upload file and invalidate queries', async () => {
84
+ const mockResult = { path: 'file.txt' };
85
+ const uploadMock = vi.fn().mockResolvedValue({ data: mockResult, error: null });
86
+ const fromMock = vi.fn().mockReturnValue({ upload: uploadMock });
87
+
88
+ const client = createMockClient({
89
+ storage: { from: fromMock },
90
+ } as any);
91
+
92
+ const queryClient = createTestQueryClient();
93
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
94
+
95
+ const { result } = renderHook(() => useStorageUpload('bucket'), {
96
+ wrapper: createWrapper(client, queryClient),
97
+ });
98
+
99
+ const file = new Blob(['content']);
100
+ await act(async () => {
101
+ await result.current.mutateAsync({ path: 'file.txt', file });
102
+ });
103
+
104
+ expect(fromMock).toHaveBeenCalledWith('bucket');
105
+ expect(uploadMock).toHaveBeenCalledWith('file.txt', file, undefined);
106
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'bucket', 'list'] });
107
+ });
108
+
109
+ it('should throw error on upload failure', async () => {
110
+ const error = new Error('Upload failed');
111
+ const uploadMock = vi.fn().mockResolvedValue({ data: null, error });
112
+ const fromMock = vi.fn().mockReturnValue({ upload: uploadMock });
113
+
114
+ const client = createMockClient({
115
+ storage: { from: fromMock },
116
+ } as any);
117
+
118
+ const { result } = renderHook(() => useStorageUpload('bucket'), {
119
+ wrapper: createWrapper(client),
120
+ });
121
+
122
+ const file = new Blob(['content']);
123
+ await expect(act(async () => {
124
+ await result.current.mutateAsync({ path: 'file.txt', file });
125
+ })).rejects.toThrow('Upload failed');
126
+ });
127
+ });
128
+
129
+ describe('useStorageUploadWithProgress', () => {
130
+ it('should track upload progress', async () => {
131
+ let progressCallback: Function | undefined;
132
+ let resolveUpload: Function;
133
+
134
+ const uploadMock = vi.fn().mockImplementation((path, file, options) => {
135
+ progressCallback = options?.onUploadProgress;
136
+ return new Promise((resolve) => {
137
+ resolveUpload = () => resolve({ data: { path }, error: null });
138
+ });
139
+ });
140
+ const fromMock = vi.fn().mockReturnValue({ upload: uploadMock });
141
+
142
+ const client = createMockClient({
143
+ storage: { from: fromMock },
144
+ } as any);
145
+
146
+ const { result } = renderHook(() => useStorageUploadWithProgress('bucket'), {
147
+ wrapper: createWrapper(client),
148
+ });
149
+
150
+ const file = new Blob(['content']);
151
+
152
+ // Start upload (don't await yet)
153
+ let uploadPromise: Promise<any>;
154
+ act(() => {
155
+ uploadPromise = result.current.upload.mutateAsync({ path: 'file.txt', file });
156
+ });
157
+
158
+ // Wait for upload to start and callback to be assigned
159
+ await waitFor(() => {
160
+ expect(progressCallback).toBeDefined();
161
+ });
162
+
163
+ // Simulate progress
164
+ act(() => {
165
+ progressCallback!({ loaded: 50, total: 100, percentage: 50 });
166
+ });
167
+
168
+ // Check progress state
169
+ expect(result.current.progress).toEqual({ loaded: 50, total: 100, percentage: 50 });
170
+
171
+ // Resolve upload
172
+ await act(async () => {
173
+ resolveUpload!();
174
+ await uploadPromise;
175
+ });
176
+ });
177
+
178
+ it('should reset progress on error', async () => {
179
+ const uploadMock = vi.fn().mockResolvedValue({ data: null, error: new Error('Failed') });
180
+ const fromMock = vi.fn().mockReturnValue({ upload: uploadMock });
181
+
182
+ const client = createMockClient({
183
+ storage: { from: fromMock },
184
+ } as any);
185
+
186
+ const { result } = renderHook(() => useStorageUploadWithProgress('bucket'), {
187
+ wrapper: createWrapper(client),
188
+ });
189
+
190
+ const file = new Blob(['content']);
191
+ try {
192
+ await act(async () => {
193
+ await result.current.upload.mutateAsync({ path: 'file.txt', file });
194
+ });
195
+ } catch {
196
+ // Expected error
197
+ }
198
+
199
+ expect(result.current.progress).toBeNull();
200
+ });
201
+
202
+ it('should have reset function', () => {
203
+ const client = createMockClient();
204
+
205
+ const { result } = renderHook(() => useStorageUploadWithProgress('bucket'), {
206
+ wrapper: createWrapper(client),
207
+ });
208
+
209
+ expect(result.current.reset).toBeDefined();
210
+ expect(typeof result.current.reset).toBe('function');
211
+ });
212
+ });
213
+
214
+ describe('useStorageDownload', () => {
215
+ it('should download file', async () => {
216
+ const mockBlob = new Blob(['content']);
217
+ const downloadMock = vi.fn().mockResolvedValue({ data: mockBlob, error: null });
218
+ const fromMock = vi.fn().mockReturnValue({ download: downloadMock });
219
+
220
+ const client = createMockClient({
221
+ storage: { from: fromMock },
222
+ } as any);
223
+
224
+ const { result } = renderHook(
225
+ () => useStorageDownload('bucket', 'file.txt'),
226
+ { wrapper: createWrapper(client) }
227
+ );
228
+
229
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
230
+ expect(result.current.data).toBe(mockBlob);
231
+ expect(downloadMock).toHaveBeenCalledWith('file.txt');
232
+ });
233
+
234
+ it('should not fetch when path is null', async () => {
235
+ const downloadMock = vi.fn();
236
+ const fromMock = vi.fn().mockReturnValue({ download: downloadMock });
237
+
238
+ const client = createMockClient({
239
+ storage: { from: fromMock },
240
+ } as any);
241
+
242
+ const { result } = renderHook(
243
+ () => useStorageDownload('bucket', null),
244
+ { wrapper: createWrapper(client) }
245
+ );
246
+
247
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
248
+ expect(downloadMock).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it('should not fetch when disabled', async () => {
252
+ const downloadMock = vi.fn();
253
+ const fromMock = vi.fn().mockReturnValue({ download: downloadMock });
254
+
255
+ const client = createMockClient({
256
+ storage: { from: fromMock },
257
+ } as any);
258
+
259
+ const { result } = renderHook(
260
+ () => useStorageDownload('bucket', 'file.txt', false),
261
+ { wrapper: createWrapper(client) }
262
+ );
263
+
264
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
265
+ expect(downloadMock).not.toHaveBeenCalled();
266
+ });
267
+ });
268
+
269
+ describe('useStorageDelete', () => {
270
+ it('should delete files and invalidate queries', async () => {
271
+ const removeMock = vi.fn().mockResolvedValue({ error: null });
272
+ const fromMock = vi.fn().mockReturnValue({ remove: removeMock });
273
+
274
+ const client = createMockClient({
275
+ storage: { from: fromMock },
276
+ } as any);
277
+
278
+ const queryClient = createTestQueryClient();
279
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
280
+
281
+ const { result } = renderHook(() => useStorageDelete('bucket'), {
282
+ wrapper: createWrapper(client, queryClient),
283
+ });
284
+
285
+ await act(async () => {
286
+ await result.current.mutateAsync(['file1.txt', 'file2.txt']);
287
+ });
288
+
289
+ expect(removeMock).toHaveBeenCalledWith(['file1.txt', 'file2.txt']);
290
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'bucket', 'list'] });
291
+ });
292
+
293
+ it('should throw error on delete failure', async () => {
294
+ const error = new Error('Delete failed');
295
+ const removeMock = vi.fn().mockResolvedValue({ error });
296
+ const fromMock = vi.fn().mockReturnValue({ remove: removeMock });
297
+
298
+ const client = createMockClient({
299
+ storage: { from: fromMock },
300
+ } as any);
301
+
302
+ const { result } = renderHook(() => useStorageDelete('bucket'), {
303
+ wrapper: createWrapper(client),
304
+ });
305
+
306
+ await expect(act(async () => {
307
+ await result.current.mutateAsync(['file.txt']);
308
+ })).rejects.toThrow('Delete failed');
309
+ });
310
+ });
311
+
312
+ describe('useStoragePublicUrl', () => {
313
+ it('should return public URL', () => {
314
+ const getPublicUrlMock = vi.fn().mockReturnValue({ data: { publicUrl: 'http://example.com/file' } });
315
+ const fromMock = vi.fn().mockReturnValue({ getPublicUrl: getPublicUrlMock });
316
+
317
+ const client = createMockClient({
318
+ storage: { from: fromMock },
319
+ } as any);
320
+
321
+ const { result } = renderHook(
322
+ () => useStoragePublicUrl('bucket', 'file.txt'),
323
+ { wrapper: createWrapper(client) }
324
+ );
325
+
326
+ expect(result.current).toBe('http://example.com/file');
327
+ expect(getPublicUrlMock).toHaveBeenCalledWith('file.txt');
328
+ });
329
+
330
+ it('should return null when path is null', () => {
331
+ const client = createMockClient();
332
+
333
+ const { result } = renderHook(
334
+ () => useStoragePublicUrl('bucket', null),
335
+ { wrapper: createWrapper(client) }
336
+ );
337
+
338
+ expect(result.current).toBeNull();
339
+ });
340
+ });
341
+
342
+ describe('useStorageTransformUrl', () => {
343
+ it('should return transform URL', () => {
344
+ const getTransformUrlMock = vi.fn().mockReturnValue('http://example.com/transform/file');
345
+ const fromMock = vi.fn().mockReturnValue({ getTransformUrl: getTransformUrlMock });
346
+
347
+ const client = createMockClient({
348
+ storage: { from: fromMock },
349
+ } as any);
350
+
351
+ const { result } = renderHook(
352
+ () => useStorageTransformUrl('bucket', 'file.jpg', { width: 100, height: 100 }),
353
+ { wrapper: createWrapper(client) }
354
+ );
355
+
356
+ expect(result.current).toBe('http://example.com/transform/file');
357
+ expect(getTransformUrlMock).toHaveBeenCalledWith('file.jpg', { width: 100, height: 100 });
358
+ });
359
+
360
+ it('should return null when path is null', () => {
361
+ const client = createMockClient();
362
+
363
+ const { result } = renderHook(
364
+ () => useStorageTransformUrl('bucket', null, { width: 100 }),
365
+ { wrapper: createWrapper(client) }
366
+ );
367
+
368
+ expect(result.current).toBeNull();
369
+ });
370
+ });
371
+
372
+ describe('useStorageSignedUrl', () => {
373
+ it('should fetch signed URL', async () => {
374
+ const createSignedUrlMock = vi.fn().mockResolvedValue({ data: { signedUrl: 'http://example.com/signed' }, error: null });
375
+ const fromMock = vi.fn().mockReturnValue({ createSignedUrl: createSignedUrlMock });
376
+
377
+ const client = createMockClient({
378
+ storage: { from: fromMock },
379
+ } as any);
380
+
381
+ const { result } = renderHook(
382
+ () => useStorageSignedUrl('bucket', 'file.txt', 3600),
383
+ { wrapper: createWrapper(client) }
384
+ );
385
+
386
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
387
+ expect(result.current.data).toBe('http://example.com/signed');
388
+ expect(createSignedUrlMock).toHaveBeenCalledWith('file.txt', { expiresIn: 3600 });
389
+ });
390
+
391
+ it('should not fetch when path is null', async () => {
392
+ const createSignedUrlMock = vi.fn();
393
+ const fromMock = vi.fn().mockReturnValue({ createSignedUrl: createSignedUrlMock });
394
+
395
+ const client = createMockClient({
396
+ storage: { from: fromMock },
397
+ } as any);
398
+
399
+ const { result } = renderHook(
400
+ () => useStorageSignedUrl('bucket', null),
401
+ { wrapper: createWrapper(client) }
402
+ );
403
+
404
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
405
+ expect(createSignedUrlMock).not.toHaveBeenCalled();
406
+ });
407
+ });
408
+
409
+ describe('useStorageSignedUrlWithOptions', () => {
410
+ it('should fetch signed URL with transform options', async () => {
411
+ const createSignedUrlMock = vi.fn().mockResolvedValue({ data: { signedUrl: 'http://example.com/signed' }, error: null });
412
+ const fromMock = vi.fn().mockReturnValue({ createSignedUrl: createSignedUrlMock });
413
+
414
+ const client = createMockClient({
415
+ storage: { from: fromMock },
416
+ } as any);
417
+
418
+ const options = {
419
+ expiresIn: 3600,
420
+ transform: { width: 100, height: 100 },
421
+ };
422
+
423
+ const { result } = renderHook(
424
+ () => useStorageSignedUrlWithOptions('bucket', 'file.jpg', options),
425
+ { wrapper: createWrapper(client) }
426
+ );
427
+
428
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
429
+ expect(result.current.data).toBe('http://example.com/signed');
430
+ expect(createSignedUrlMock).toHaveBeenCalledWith('file.jpg', options);
431
+ });
432
+ });
433
+
434
+ describe('useStorageMove', () => {
435
+ it('should move file and invalidate queries', async () => {
436
+ const moveMock = vi.fn().mockResolvedValue({ data: { path: 'new.txt' }, error: null });
437
+ const fromMock = vi.fn().mockReturnValue({ move: moveMock });
438
+
439
+ const client = createMockClient({
440
+ storage: { from: fromMock },
441
+ } as any);
442
+
443
+ const queryClient = createTestQueryClient();
444
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
445
+
446
+ const { result } = renderHook(() => useStorageMove('bucket'), {
447
+ wrapper: createWrapper(client, queryClient),
448
+ });
449
+
450
+ await act(async () => {
451
+ await result.current.mutateAsync({ fromPath: 'old.txt', toPath: 'new.txt' });
452
+ });
453
+
454
+ expect(moveMock).toHaveBeenCalledWith('old.txt', 'new.txt');
455
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'bucket', 'list'] });
456
+ });
457
+ });
458
+
459
+ describe('useStorageCopy', () => {
460
+ it('should copy file and invalidate queries', async () => {
461
+ const copyMock = vi.fn().mockResolvedValue({ data: { path: 'copy.txt' }, error: null });
462
+ const fromMock = vi.fn().mockReturnValue({ copy: copyMock });
463
+
464
+ const client = createMockClient({
465
+ storage: { from: fromMock },
466
+ } as any);
467
+
468
+ const queryClient = createTestQueryClient();
469
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
470
+
471
+ const { result } = renderHook(() => useStorageCopy('bucket'), {
472
+ wrapper: createWrapper(client, queryClient),
473
+ });
474
+
475
+ await act(async () => {
476
+ await result.current.mutateAsync({ fromPath: 'source.txt', toPath: 'copy.txt' });
477
+ });
478
+
479
+ expect(copyMock).toHaveBeenCalledWith('source.txt', 'copy.txt');
480
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'bucket', 'list'] });
481
+ });
482
+ });
483
+
484
+ describe('useStorageBuckets', () => {
485
+ it('should list buckets', async () => {
486
+ const mockBuckets = [{ name: 'bucket1' }, { name: 'bucket2' }];
487
+ const listBucketsMock = vi.fn().mockResolvedValue({ data: mockBuckets, error: null });
488
+
489
+ const client = createMockClient({
490
+ storage: { listBuckets: listBucketsMock },
491
+ } as any);
492
+
493
+ const { result } = renderHook(
494
+ () => useStorageBuckets(),
495
+ { wrapper: createWrapper(client) }
496
+ );
497
+
498
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
499
+ expect(result.current.data).toEqual(mockBuckets);
500
+ });
501
+ });
502
+
503
+ describe('useCreateBucket', () => {
504
+ it('should create bucket and invalidate queries', async () => {
505
+ const createBucketMock = vi.fn().mockResolvedValue({ error: null });
506
+
507
+ const client = createMockClient({
508
+ storage: { createBucket: createBucketMock },
509
+ } as any);
510
+
511
+ const queryClient = createTestQueryClient();
512
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
513
+
514
+ const { result } = renderHook(() => useCreateBucket(), {
515
+ wrapper: createWrapper(client, queryClient),
516
+ });
517
+
518
+ await act(async () => {
519
+ await result.current.mutateAsync('new-bucket');
520
+ });
521
+
522
+ expect(createBucketMock).toHaveBeenCalledWith('new-bucket');
523
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'buckets'] });
524
+ });
525
+ });
526
+
527
+ describe('useDeleteBucket', () => {
528
+ it('should delete bucket and invalidate queries', async () => {
529
+ const deleteBucketMock = vi.fn().mockResolvedValue({ error: null });
530
+
531
+ const client = createMockClient({
532
+ storage: { deleteBucket: deleteBucketMock },
533
+ } as any);
534
+
535
+ const queryClient = createTestQueryClient();
536
+ const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries');
537
+
538
+ const { result } = renderHook(() => useDeleteBucket(), {
539
+ wrapper: createWrapper(client, queryClient),
540
+ });
541
+
542
+ await act(async () => {
543
+ await result.current.mutateAsync('bucket-to-delete');
544
+ });
545
+
546
+ expect(deleteBucketMock).toHaveBeenCalledWith('bucket-to-delete');
547
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['fluxbase', 'storage', 'buckets'] });
548
+ });
549
+ });
@@ -305,7 +305,11 @@ export function useStorageSignedUrl(
305
305
  return data?.signedUrl || null;
306
306
  },
307
307
  enabled: !!path,
308
- staleTime: expiresIn ? expiresIn * 1000 - 60000 : 1000 * 60 * 50, // Refresh 1 minute before expiry
308
+ // Refresh 1 minute before expiry, but ensure staleTime is never negative
309
+ // For very short expirations (<60s), use half the expiration time
310
+ staleTime: expiresIn
311
+ ? Math.max(expiresIn * 500, expiresIn * 1000 - 60000) // At least half the expiration time
312
+ : 1000 * 60 * 50, // 50 minutes default
309
313
  });
310
314
  }
311
315
 
@@ -373,7 +377,11 @@ export function useStorageSignedUrlWithOptions(
373
377
  return data?.signedUrl || null;
374
378
  },
375
379
  enabled: !!path,
376
- staleTime: expiresIn ? expiresIn * 1000 - 60000 : 1000 * 60 * 50, // Refresh 1 minute before expiry
380
+ // Refresh 1 minute before expiry, but ensure staleTime is never negative
381
+ // For very short expirations (<60s), use half the expiration time
382
+ staleTime: expiresIn
383
+ ? Math.max(expiresIn * 500, expiresIn * 1000 - 60000) // At least half the expiration time
384
+ : 1000 * 60 * 50, // 50 minutes default
377
385
  });
378
386
  }
379
387