@lobehub/chat 1.112.4 → 1.113.0

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,846 @@
1
+ // @vitest-environment node
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { CreateImagePayload } from '@/libs/model-runtime/types/image';
5
+
6
+ import { createBflImage } from './createImage';
7
+ import { BflStatusResponse } from './types';
8
+
9
+ // Mock external dependencies
10
+ vi.mock('@/utils/imageToBase64', () => ({
11
+ imageUrlToBase64: vi.fn(),
12
+ }));
13
+
14
+ vi.mock('../utils/uriParser', () => ({
15
+ parseDataUri: vi.fn(),
16
+ }));
17
+
18
+ vi.mock('../utils/asyncifyPolling', () => ({
19
+ asyncifyPolling: vi.fn(),
20
+ }));
21
+
22
+ // Mock fetch
23
+ global.fetch = vi.fn();
24
+ const mockFetch = vi.mocked(fetch);
25
+
26
+ // Mock the console.error to avoid polluting test output
27
+ vi.spyOn(console, 'error').mockImplementation(() => {});
28
+
29
+ const mockOptions = {
30
+ apiKey: 'test-api-key',
31
+ provider: 'bfl' as const,
32
+ };
33
+
34
+ beforeEach(() => {
35
+ vi.clearAllMocks();
36
+ });
37
+
38
+ afterEach(() => {
39
+ vi.clearAllMocks();
40
+ });
41
+
42
+ describe('createBflImage', () => {
43
+ describe('Parameter mapping and defaults', () => {
44
+ it('should map standard parameters to BFL-specific parameters', async () => {
45
+ // Arrange
46
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
47
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
48
+
49
+ mockFetch.mockResolvedValueOnce({
50
+ ok: true,
51
+ json: () =>
52
+ Promise.resolve({
53
+ id: 'task-123',
54
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
55
+ }),
56
+ } as Response);
57
+
58
+ mockAsyncifyPolling.mockResolvedValue({
59
+ imageUrl: 'https://example.com/result.jpg',
60
+ });
61
+
62
+ const payload: CreateImagePayload = {
63
+ model: 'flux-dev',
64
+ params: {
65
+ prompt: 'A beautiful landscape',
66
+ aspectRatio: '16:9',
67
+ cfg: 7.5,
68
+ steps: 20,
69
+ seed: 12345,
70
+ },
71
+ };
72
+
73
+ // Act
74
+ await createBflImage(payload, mockOptions);
75
+
76
+ // Assert
77
+ expect(mockFetch).toHaveBeenCalledWith(
78
+ 'https://api.bfl.ai/v1/flux-dev',
79
+ expect.objectContaining({
80
+ method: 'POST',
81
+ headers: {
82
+ 'Content-Type': 'application/json',
83
+ 'x-key': 'test-api-key',
84
+ },
85
+ body: JSON.stringify({
86
+ output_format: 'png',
87
+ safety_tolerance: 6,
88
+ prompt: 'A beautiful landscape',
89
+ aspect_ratio: '16:9',
90
+ guidance: 7.5,
91
+ steps: 20,
92
+ seed: 12345,
93
+ }),
94
+ }),
95
+ );
96
+ });
97
+
98
+ it('should add raw: true for ultra models', async () => {
99
+ // Arrange
100
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
101
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
102
+
103
+ mockFetch.mockResolvedValueOnce({
104
+ ok: true,
105
+ json: () =>
106
+ Promise.resolve({
107
+ id: 'task-123',
108
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
109
+ }),
110
+ } as Response);
111
+
112
+ mockAsyncifyPolling.mockResolvedValue({
113
+ imageUrl: 'https://example.com/result.jpg',
114
+ });
115
+
116
+ const payload: CreateImagePayload = {
117
+ model: 'flux-pro-1.1-ultra',
118
+ params: {
119
+ prompt: 'Ultra quality image',
120
+ },
121
+ };
122
+
123
+ // Act
124
+ await createBflImage(payload, mockOptions);
125
+
126
+ // Assert
127
+ expect(mockFetch).toHaveBeenCalledWith(
128
+ 'https://api.bfl.ai/v1/flux-pro-1.1-ultra',
129
+ expect.objectContaining({
130
+ body: JSON.stringify({
131
+ output_format: 'png',
132
+ safety_tolerance: 6,
133
+ raw: true,
134
+ prompt: 'Ultra quality image',
135
+ }),
136
+ }),
137
+ );
138
+ });
139
+
140
+ it('should filter out undefined values', async () => {
141
+ // Arrange
142
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
143
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
144
+
145
+ mockFetch.mockResolvedValueOnce({
146
+ ok: true,
147
+ json: () =>
148
+ Promise.resolve({
149
+ id: 'task-123',
150
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
151
+ }),
152
+ } as Response);
153
+
154
+ mockAsyncifyPolling.mockResolvedValue({
155
+ imageUrl: 'https://example.com/result.jpg',
156
+ });
157
+
158
+ const payload: CreateImagePayload = {
159
+ model: 'flux-dev',
160
+ params: {
161
+ prompt: 'Test image',
162
+ cfg: undefined,
163
+ seed: 12345,
164
+ steps: undefined,
165
+ } as any,
166
+ };
167
+
168
+ // Act
169
+ await createBflImage(payload, mockOptions);
170
+
171
+ // Assert
172
+ const callArgs = mockFetch.mock.calls[0][1];
173
+ const requestBody = JSON.parse(callArgs?.body as string);
174
+
175
+ expect(requestBody).toEqual({
176
+ output_format: 'png',
177
+ safety_tolerance: 6,
178
+ prompt: 'Test image',
179
+ seed: 12345,
180
+ });
181
+
182
+ expect(requestBody).not.toHaveProperty('guidance');
183
+ expect(requestBody).not.toHaveProperty('steps');
184
+ });
185
+ });
186
+
187
+ describe('Image URL handling', () => {
188
+ it('should convert single imageUrl to image_prompt base64', async () => {
189
+ // Arrange
190
+ const { parseDataUri } = await import('../utils/uriParser');
191
+ const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
192
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
193
+
194
+ const mockParseDataUri = vi.mocked(parseDataUri);
195
+ const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
196
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
197
+
198
+ mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
199
+ mockImageUrlToBase64.mockResolvedValue({
200
+ base64: 'base64EncodedImage',
201
+ mimeType: 'image/jpeg',
202
+ });
203
+
204
+ mockFetch.mockResolvedValueOnce({
205
+ ok: true,
206
+ json: () =>
207
+ Promise.resolve({
208
+ id: 'task-123',
209
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
210
+ }),
211
+ } as Response);
212
+
213
+ mockAsyncifyPolling.mockResolvedValue({
214
+ imageUrl: 'https://example.com/result.jpg',
215
+ });
216
+
217
+ const payload: CreateImagePayload = {
218
+ model: 'flux-pro-1.1',
219
+ params: {
220
+ prompt: 'Transform this image',
221
+ imageUrl: 'https://example.com/input.jpg',
222
+ },
223
+ };
224
+
225
+ // Act
226
+ await createBflImage(payload, mockOptions);
227
+
228
+ // Assert
229
+ expect(mockParseDataUri).toHaveBeenCalledWith('https://example.com/input.jpg');
230
+ expect(mockImageUrlToBase64).toHaveBeenCalledWith('https://example.com/input.jpg');
231
+
232
+ const callArgs = mockFetch.mock.calls[0][1];
233
+ const requestBody = JSON.parse(callArgs?.body as string);
234
+
235
+ expect(requestBody).toEqual({
236
+ output_format: 'png',
237
+ safety_tolerance: 6,
238
+ prompt: 'Transform this image',
239
+ image_prompt: 'base64EncodedImage',
240
+ });
241
+
242
+ expect(requestBody).not.toHaveProperty('imageUrl');
243
+ });
244
+
245
+ it('should handle base64 imageUrl directly', async () => {
246
+ // Arrange
247
+ const { parseDataUri } = await import('../utils/uriParser');
248
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
249
+
250
+ const mockParseDataUri = vi.mocked(parseDataUri);
251
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
252
+
253
+ mockParseDataUri.mockReturnValue({
254
+ type: 'base64',
255
+ base64: '/9j/4AAQSkZJRgABAQEAYABgAAD',
256
+ mimeType: 'image/jpeg',
257
+ });
258
+
259
+ mockFetch.mockResolvedValueOnce({
260
+ ok: true,
261
+ json: () =>
262
+ Promise.resolve({
263
+ id: 'task-123',
264
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
265
+ }),
266
+ } as Response);
267
+
268
+ mockAsyncifyPolling.mockResolvedValue({
269
+ imageUrl: 'https://example.com/result.jpg',
270
+ });
271
+
272
+ const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD';
273
+ const payload: CreateImagePayload = {
274
+ model: 'flux-pro-1.1',
275
+ params: {
276
+ prompt: 'Transform this image',
277
+ imageUrl: base64Image,
278
+ },
279
+ };
280
+
281
+ // Act
282
+ await createBflImage(payload, mockOptions);
283
+
284
+ // Assert
285
+ const callArgs = mockFetch.mock.calls[0][1];
286
+ const requestBody = JSON.parse(callArgs?.body as string);
287
+
288
+ expect(requestBody.image_prompt).toBe('/9j/4AAQSkZJRgABAQEAYABgAAD');
289
+ });
290
+
291
+ it('should convert multiple imageUrls for Kontext models', async () => {
292
+ // Arrange
293
+ const { parseDataUri } = await import('../utils/uriParser');
294
+ const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
295
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
296
+
297
+ const mockParseDataUri = vi.mocked(parseDataUri);
298
+ const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
299
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
300
+
301
+ mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
302
+ mockImageUrlToBase64
303
+ .mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' })
304
+ .mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' })
305
+ .mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' });
306
+
307
+ mockFetch.mockResolvedValueOnce({
308
+ ok: true,
309
+ json: () =>
310
+ Promise.resolve({
311
+ id: 'task-123',
312
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
313
+ }),
314
+ } as Response);
315
+
316
+ mockAsyncifyPolling.mockResolvedValue({
317
+ imageUrl: 'https://example.com/result.jpg',
318
+ });
319
+
320
+ const payload: CreateImagePayload = {
321
+ model: 'flux-kontext-pro',
322
+ params: {
323
+ prompt: 'Create variation of these images',
324
+ imageUrls: [
325
+ 'https://example.com/input1.jpg',
326
+ 'https://example.com/input2.jpg',
327
+ 'https://example.com/input3.jpg',
328
+ ],
329
+ },
330
+ };
331
+
332
+ // Act
333
+ await createBflImage(payload, mockOptions);
334
+
335
+ // Assert
336
+ const callArgs = mockFetch.mock.calls[0][1];
337
+ const requestBody = JSON.parse(callArgs?.body as string);
338
+
339
+ expect(requestBody).toEqual({
340
+ output_format: 'png',
341
+ safety_tolerance: 6,
342
+ prompt: 'Create variation of these images',
343
+ input_image: 'base64image1',
344
+ input_image_2: 'base64image2',
345
+ input_image_3: 'base64image3',
346
+ });
347
+
348
+ expect(requestBody).not.toHaveProperty('imageUrls');
349
+ });
350
+
351
+ it('should limit imageUrls to maximum 4 images', async () => {
352
+ // Arrange
353
+ const { parseDataUri } = await import('../utils/uriParser');
354
+ const { imageUrlToBase64 } = await import('@/utils/imageToBase64');
355
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
356
+
357
+ const mockParseDataUri = vi.mocked(parseDataUri);
358
+ const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64);
359
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
360
+
361
+ mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null });
362
+ mockImageUrlToBase64
363
+ .mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' })
364
+ .mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' })
365
+ .mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' })
366
+ .mockResolvedValueOnce({ base64: 'base64image4', mimeType: 'image/jpeg' });
367
+
368
+ mockFetch.mockResolvedValueOnce({
369
+ ok: true,
370
+ json: () =>
371
+ Promise.resolve({
372
+ id: 'task-123',
373
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
374
+ }),
375
+ } as Response);
376
+
377
+ mockAsyncifyPolling.mockResolvedValue({
378
+ imageUrl: 'https://example.com/result.jpg',
379
+ });
380
+
381
+ const payload: CreateImagePayload = {
382
+ model: 'flux-kontext-max',
383
+ params: {
384
+ prompt: 'Create variation of these images',
385
+ imageUrls: [
386
+ 'https://example.com/input1.jpg',
387
+ 'https://example.com/input2.jpg',
388
+ 'https://example.com/input3.jpg',
389
+ 'https://example.com/input4.jpg',
390
+ 'https://example.com/input5.jpg', // This should be ignored
391
+ ],
392
+ },
393
+ };
394
+
395
+ // Act
396
+ await createBflImage(payload, mockOptions);
397
+
398
+ // Assert
399
+ expect(mockImageUrlToBase64).toHaveBeenCalledTimes(4);
400
+
401
+ const callArgs = mockFetch.mock.calls[0][1];
402
+ const requestBody = JSON.parse(callArgs?.body as string);
403
+
404
+ expect(requestBody).toEqual({
405
+ output_format: 'png',
406
+ safety_tolerance: 6,
407
+ prompt: 'Create variation of these images',
408
+ input_image: 'base64image1',
409
+ input_image_2: 'base64image2',
410
+ input_image_3: 'base64image3',
411
+ input_image_4: 'base64image4',
412
+ });
413
+
414
+ expect(requestBody).not.toHaveProperty('input_image_5');
415
+ });
416
+ });
417
+
418
+ describe('Model endpoint mapping', () => {
419
+ it('should map models to correct endpoints', async () => {
420
+ // Arrange
421
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
422
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
423
+
424
+ mockFetch.mockResolvedValue({
425
+ ok: true,
426
+ json: () =>
427
+ Promise.resolve({
428
+ id: 'task-123',
429
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
430
+ }),
431
+ } as Response);
432
+
433
+ mockAsyncifyPolling.mockResolvedValue({
434
+ imageUrl: 'https://example.com/result.jpg',
435
+ });
436
+
437
+ const testCases = [
438
+ { model: 'flux-dev', endpoint: '/v1/flux-dev' },
439
+ { model: 'flux-pro', endpoint: '/v1/flux-pro' },
440
+ { model: 'flux-pro-1.1', endpoint: '/v1/flux-pro-1.1' },
441
+ { model: 'flux-pro-1.1-ultra', endpoint: '/v1/flux-pro-1.1-ultra' },
442
+ { model: 'flux-kontext-pro', endpoint: '/v1/flux-kontext-pro' },
443
+ { model: 'flux-kontext-max', endpoint: '/v1/flux-kontext-max' },
444
+ ];
445
+
446
+ // Act & Assert
447
+ for (const { model, endpoint } of testCases) {
448
+ vi.clearAllMocks();
449
+
450
+ const payload: CreateImagePayload = {
451
+ model,
452
+ params: {
453
+ prompt: `Test image for ${model}`,
454
+ },
455
+ };
456
+
457
+ await createBflImage(payload, mockOptions);
458
+
459
+ expect(mockFetch).toHaveBeenCalledWith(`https://api.bfl.ai${endpoint}`, expect.any(Object));
460
+ }
461
+ });
462
+
463
+ it('should throw error for unsupported model', async () => {
464
+ // Arrange
465
+ const payload: CreateImagePayload = {
466
+ model: 'unsupported-model',
467
+ params: {
468
+ prompt: 'Test image',
469
+ },
470
+ };
471
+
472
+ // Act & Assert
473
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
474
+ error: expect.objectContaining({
475
+ message: 'Unsupported BFL model: unsupported-model',
476
+ }),
477
+ errorType: 'ModelNotFound',
478
+ provider: 'bfl',
479
+ });
480
+ });
481
+
482
+ it('should use custom baseURL when provided', async () => {
483
+ // Arrange
484
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
485
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
486
+
487
+ mockFetch.mockResolvedValueOnce({
488
+ ok: true,
489
+ json: () =>
490
+ Promise.resolve({
491
+ id: 'task-123',
492
+ polling_url: 'https://custom-api.bfl.ai/v1/get_result?id=task-123',
493
+ }),
494
+ } as Response);
495
+
496
+ mockAsyncifyPolling.mockResolvedValue({
497
+ imageUrl: 'https://example.com/result.jpg',
498
+ });
499
+
500
+ const customOptions = {
501
+ ...mockOptions,
502
+ baseURL: 'https://custom-api.bfl.ai',
503
+ };
504
+
505
+ const payload: CreateImagePayload = {
506
+ model: 'flux-dev',
507
+ params: {
508
+ prompt: 'Test with custom URL',
509
+ },
510
+ };
511
+
512
+ // Act
513
+ await createBflImage(payload, customOptions);
514
+
515
+ // Assert
516
+ expect(mockFetch).toHaveBeenCalledWith(
517
+ 'https://custom-api.bfl.ai/v1/flux-dev',
518
+ expect.any(Object),
519
+ );
520
+ });
521
+ });
522
+
523
+ describe('Status handling', () => {
524
+ it('should return success when status is Ready with result', async () => {
525
+ // Arrange
526
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
527
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
528
+
529
+ mockFetch.mockResolvedValueOnce({
530
+ ok: true,
531
+ json: () =>
532
+ Promise.resolve({
533
+ id: 'task-123',
534
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
535
+ }),
536
+ } as Response);
537
+
538
+ // Mock the asyncifyPolling to call checkStatus with Ready status
539
+ mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
540
+ const result = checkStatus({
541
+ id: 'task-123',
542
+ status: BflStatusResponse.Ready,
543
+ result: {
544
+ sample: 'https://example.com/generated-image.jpg',
545
+ },
546
+ });
547
+
548
+ if (result.status === 'success') {
549
+ return result.data;
550
+ }
551
+ throw result.error;
552
+ });
553
+
554
+ const payload: CreateImagePayload = {
555
+ model: 'flux-dev',
556
+ params: {
557
+ prompt: 'Test successful generation',
558
+ },
559
+ };
560
+
561
+ // Act
562
+ const result = await createBflImage(payload, mockOptions);
563
+
564
+ // Assert
565
+ expect(result).toEqual({
566
+ imageUrl: 'https://example.com/generated-image.jpg',
567
+ });
568
+ });
569
+
570
+ it('should throw error when status is Ready but no result', async () => {
571
+ // Arrange
572
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
573
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
574
+
575
+ mockFetch.mockResolvedValueOnce({
576
+ ok: true,
577
+ json: () =>
578
+ Promise.resolve({
579
+ id: 'task-123',
580
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
581
+ }),
582
+ } as Response);
583
+
584
+ mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
585
+ const result = checkStatus({
586
+ id: 'task-123',
587
+ status: BflStatusResponse.Ready,
588
+ result: null,
589
+ });
590
+
591
+ if (result.status === 'success') {
592
+ return result.data;
593
+ }
594
+ throw result.error;
595
+ });
596
+
597
+ const payload: CreateImagePayload = {
598
+ model: 'flux-dev',
599
+ params: {
600
+ prompt: 'Test no result error',
601
+ },
602
+ };
603
+
604
+ // Act & Assert
605
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
606
+ error: expect.any(Object),
607
+ errorType: 'ProviderBizError',
608
+ provider: 'bfl',
609
+ });
610
+ });
611
+
612
+ it('should handle error statuses', async () => {
613
+ // Arrange
614
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
615
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
616
+
617
+ mockFetch.mockResolvedValueOnce({
618
+ ok: true,
619
+ json: () =>
620
+ Promise.resolve({
621
+ id: 'task-123',
622
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
623
+ }),
624
+ } as Response);
625
+
626
+ const errorStatuses = [
627
+ BflStatusResponse.Error,
628
+ BflStatusResponse.ContentModerated,
629
+ BflStatusResponse.RequestModerated,
630
+ ];
631
+
632
+ for (const status of errorStatuses) {
633
+ mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
634
+ const result = checkStatus({
635
+ id: 'task-123',
636
+ status,
637
+ details: { error: 'Test error details' },
638
+ });
639
+
640
+ if (result.status === 'success') {
641
+ return result.data;
642
+ }
643
+ throw result.error;
644
+ });
645
+
646
+ const payload: CreateImagePayload = {
647
+ model: 'flux-dev',
648
+ params: {
649
+ prompt: `Test ${status} error`,
650
+ },
651
+ };
652
+
653
+ // Act & Assert
654
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
655
+ error: expect.any(Object),
656
+ errorType: 'ProviderBizError',
657
+ provider: 'bfl',
658
+ });
659
+ }
660
+ });
661
+
662
+ it('should handle TaskNotFound status', async () => {
663
+ // Arrange
664
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
665
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
666
+
667
+ mockFetch.mockResolvedValueOnce({
668
+ ok: true,
669
+ json: () =>
670
+ Promise.resolve({
671
+ id: 'task-123',
672
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
673
+ }),
674
+ } as Response);
675
+
676
+ mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
677
+ const result = checkStatus({
678
+ id: 'task-123',
679
+ status: BflStatusResponse.TaskNotFound,
680
+ });
681
+
682
+ if (result.status === 'success') {
683
+ return result.data;
684
+ }
685
+ throw result.error;
686
+ });
687
+
688
+ const payload: CreateImagePayload = {
689
+ model: 'flux-dev',
690
+ params: {
691
+ prompt: 'Test task not found',
692
+ },
693
+ };
694
+
695
+ // Act & Assert
696
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
697
+ error: expect.any(Object),
698
+ errorType: 'ProviderBizError',
699
+ provider: 'bfl',
700
+ });
701
+ });
702
+
703
+ it('should continue polling for Pending status', async () => {
704
+ // Arrange
705
+ const { asyncifyPolling } = await import('../utils/asyncifyPolling');
706
+ const mockAsyncifyPolling = vi.mocked(asyncifyPolling);
707
+
708
+ mockFetch.mockResolvedValueOnce({
709
+ ok: true,
710
+ json: () =>
711
+ Promise.resolve({
712
+ id: 'task-123',
713
+ polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123',
714
+ }),
715
+ } as Response);
716
+
717
+ mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => {
718
+ // First call - Pending status
719
+ const pendingResult = checkStatus({
720
+ id: 'task-123',
721
+ status: BflStatusResponse.Pending,
722
+ });
723
+
724
+ expect(pendingResult.status).toBe('pending');
725
+
726
+ // Simulate successful completion
727
+ const successResult = checkStatus({
728
+ id: 'task-123',
729
+ status: BflStatusResponse.Ready,
730
+ result: {
731
+ sample: 'https://example.com/generated-image.jpg',
732
+ },
733
+ });
734
+
735
+ return successResult.data;
736
+ });
737
+
738
+ const payload: CreateImagePayload = {
739
+ model: 'flux-dev',
740
+ params: {
741
+ prompt: 'Test pending status',
742
+ },
743
+ };
744
+
745
+ // Act
746
+ const result = await createBflImage(payload, mockOptions);
747
+
748
+ // Assert
749
+ expect(result).toEqual({
750
+ imageUrl: 'https://example.com/generated-image.jpg',
751
+ });
752
+ });
753
+ });
754
+
755
+ describe('Error handling', () => {
756
+ it('should handle fetch errors during task submission', async () => {
757
+ // Arrange
758
+ mockFetch.mockRejectedValue(new Error('Network error'));
759
+
760
+ const payload: CreateImagePayload = {
761
+ model: 'flux-dev',
762
+ params: {
763
+ prompt: 'Test network error',
764
+ },
765
+ };
766
+
767
+ // Act & Assert
768
+ await expect(createBflImage(payload, mockOptions)).rejects.toThrow();
769
+ });
770
+
771
+ it('should handle HTTP error responses', async () => {
772
+ // Arrange
773
+ mockFetch.mockResolvedValueOnce({
774
+ ok: false,
775
+ status: 400,
776
+ statusText: 'Bad Request',
777
+ json: () =>
778
+ Promise.resolve({
779
+ detail: [{ msg: 'Invalid prompt' }],
780
+ }),
781
+ } as Response);
782
+
783
+ const payload: CreateImagePayload = {
784
+ model: 'flux-dev',
785
+ params: {
786
+ prompt: 'Test HTTP error',
787
+ },
788
+ };
789
+
790
+ // Act & Assert
791
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
792
+ error: expect.any(Object),
793
+ errorType: 'ProviderBizError',
794
+ provider: 'bfl',
795
+ });
796
+ });
797
+
798
+ it('should handle HTTP error responses without detail', async () => {
799
+ // Arrange
800
+ mockFetch.mockResolvedValueOnce({
801
+ ok: false,
802
+ status: 500,
803
+ statusText: 'Internal Server Error',
804
+ json: () => Promise.resolve({}),
805
+ } as Response);
806
+
807
+ const payload: CreateImagePayload = {
808
+ model: 'flux-dev',
809
+ params: {
810
+ prompt: 'Test HTTP error without detail',
811
+ },
812
+ };
813
+
814
+ // Act & Assert
815
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
816
+ error: expect.any(Object),
817
+ errorType: 'ProviderBizError',
818
+ provider: 'bfl',
819
+ });
820
+ });
821
+
822
+ it('should handle non-JSON error responses', async () => {
823
+ // Arrange
824
+ mockFetch.mockResolvedValueOnce({
825
+ ok: false,
826
+ status: 500,
827
+ statusText: 'Internal Server Error',
828
+ json: () => Promise.reject(new Error('Invalid JSON')),
829
+ } as Response);
830
+
831
+ const payload: CreateImagePayload = {
832
+ model: 'flux-dev',
833
+ params: {
834
+ prompt: 'Test non-JSON error',
835
+ },
836
+ };
837
+
838
+ // Act & Assert
839
+ await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({
840
+ error: expect.any(Object),
841
+ errorType: 'ProviderBizError',
842
+ provider: 'bfl',
843
+ });
844
+ });
845
+ });
846
+ });