UNPKG

@lobehub/chat

Version:

Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.

846 lines (711 loc) • 24.9 kB
// @vitest-environment node import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { CreateImagePayload } from '../../types/image'; import { createBflImage } from './createImage'; import { BflStatusResponse } from './types'; // Mock external dependencies vi.mock('@lobechat/utils', () => ({ imageUrlToBase64: vi.fn(), })); vi.mock('../../utils/uriParser', () => ({ parseDataUri: vi.fn(), })); vi.mock('../../utils/asyncifyPolling', () => ({ asyncifyPolling: vi.fn(), })); // Mock fetch global.fetch = vi.fn(); const mockFetch = vi.mocked(fetch); // Mock the console.error to avoid polluting test output vi.spyOn(console, 'error').mockImplementation(() => {}); const mockOptions = { apiKey: 'test-api-key', provider: 'bfl' as const, }; beforeEach(() => { vi.clearAllMocks(); }); afterEach(() => { vi.clearAllMocks(); }); describe('createBflImage', () => { describe('Parameter mapping and defaults', () => { it('should map standard parameters to BFL-specific parameters', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'A beautiful landscape', aspectRatio: '16:9', cfg: 7.5, steps: 20, seed: 12345, }, }; // Act await createBflImage(payload, mockOptions); // Assert expect(mockFetch).toHaveBeenCalledWith( 'https://api.bfl.ai/v1/flux-dev', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json', 'x-key': 'test-api-key', }, body: JSON.stringify({ output_format: 'png', safety_tolerance: 6, prompt: 'A beautiful landscape', aspect_ratio: '16:9', guidance: 7.5, steps: 20, seed: 12345, }), }), ); }); it('should add raw: true for ultra models', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-pro-1.1-ultra', params: { prompt: 'Ultra quality image', }, }; // Act await createBflImage(payload, mockOptions); // Assert expect(mockFetch).toHaveBeenCalledWith( 'https://api.bfl.ai/v1/flux-pro-1.1-ultra', expect.objectContaining({ body: JSON.stringify({ output_format: 'png', safety_tolerance: 6, raw: true, prompt: 'Ultra quality image', }), }), ); }); it('should filter out undefined values', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test image', cfg: undefined, seed: 12345, steps: undefined, } as any, }; // Act await createBflImage(payload, mockOptions); // Assert const callArgs = mockFetch.mock.calls[0][1]; const requestBody = JSON.parse(callArgs?.body as string); expect(requestBody).toEqual({ output_format: 'png', safety_tolerance: 6, prompt: 'Test image', seed: 12345, }); expect(requestBody).not.toHaveProperty('guidance'); expect(requestBody).not.toHaveProperty('steps'); }); }); describe('Image URL handling', () => { it('should convert single imageUrl to image_prompt base64', async () => { // Arrange const { parseDataUri } = await import('../../utils/uriParser'); const { imageUrlToBase64 } = await import('@lobechat/utils'); const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockParseDataUri = vi.mocked(parseDataUri); const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null }); mockImageUrlToBase64.mockResolvedValue({ base64: 'base64EncodedImage', mimeType: 'image/jpeg', }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-pro-1.1', params: { prompt: 'Transform this image', imageUrl: 'https://example.com/input.jpg', }, }; // Act await createBflImage(payload, mockOptions); // Assert expect(mockParseDataUri).toHaveBeenCalledWith('https://example.com/input.jpg'); expect(mockImageUrlToBase64).toHaveBeenCalledWith('https://example.com/input.jpg'); const callArgs = mockFetch.mock.calls[0][1]; const requestBody = JSON.parse(callArgs?.body as string); expect(requestBody).toEqual({ output_format: 'png', safety_tolerance: 6, prompt: 'Transform this image', image_prompt: 'base64EncodedImage', }); expect(requestBody).not.toHaveProperty('imageUrl'); }); it('should handle base64 imageUrl directly', async () => { // Arrange const { parseDataUri } = await import('../../utils/uriParser'); const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockParseDataUri = vi.mocked(parseDataUri); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockParseDataUri.mockReturnValue({ type: 'base64', base64: '/9j/4AAQSkZJRgABAQEAYABgAAD', mimeType: 'image/jpeg', }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const base64Image = 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQEAYABgAAD'; const payload: CreateImagePayload = { model: 'flux-pro-1.1', params: { prompt: 'Transform this image', imageUrl: base64Image, }, }; // Act await createBflImage(payload, mockOptions); // Assert const callArgs = mockFetch.mock.calls[0][1]; const requestBody = JSON.parse(callArgs?.body as string); expect(requestBody.image_prompt).toBe('/9j/4AAQSkZJRgABAQEAYABgAAD'); }); it('should convert multiple imageUrls for Kontext models', async () => { // Arrange const { parseDataUri } = await import('../../utils/uriParser'); const { imageUrlToBase64 } = await import('@lobechat/utils'); const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockParseDataUri = vi.mocked(parseDataUri); const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null }); mockImageUrlToBase64 .mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' }) .mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' }) .mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-kontext-pro', params: { prompt: 'Create variation of these images', imageUrls: [ 'https://example.com/input1.jpg', 'https://example.com/input2.jpg', 'https://example.com/input3.jpg', ], }, }; // Act await createBflImage(payload, mockOptions); // Assert const callArgs = mockFetch.mock.calls[0][1]; const requestBody = JSON.parse(callArgs?.body as string); expect(requestBody).toEqual({ output_format: 'png', safety_tolerance: 6, prompt: 'Create variation of these images', input_image: 'base64image1', input_image_2: 'base64image2', input_image_3: 'base64image3', }); expect(requestBody).not.toHaveProperty('imageUrls'); }); it('should limit imageUrls to maximum 4 images', async () => { // Arrange const { parseDataUri } = await import('../../utils/uriParser'); const { imageUrlToBase64 } = await import('@lobechat/utils'); const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockParseDataUri = vi.mocked(parseDataUri); const mockImageUrlToBase64 = vi.mocked(imageUrlToBase64); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockParseDataUri.mockReturnValue({ type: 'url', base64: null, mimeType: null }); mockImageUrlToBase64 .mockResolvedValueOnce({ base64: 'base64image1', mimeType: 'image/jpeg' }) .mockResolvedValueOnce({ base64: 'base64image2', mimeType: 'image/jpeg' }) .mockResolvedValueOnce({ base64: 'base64image3', mimeType: 'image/jpeg' }) .mockResolvedValueOnce({ base64: 'base64image4', mimeType: 'image/jpeg' }); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const payload: CreateImagePayload = { model: 'flux-kontext-max', params: { prompt: 'Create variation of these images', imageUrls: [ 'https://example.com/input1.jpg', 'https://example.com/input2.jpg', 'https://example.com/input3.jpg', 'https://example.com/input4.jpg', 'https://example.com/input5.jpg', // This should be ignored ], }, }; // Act await createBflImage(payload, mockOptions); // Assert expect(mockImageUrlToBase64).toHaveBeenCalledTimes(4); const callArgs = mockFetch.mock.calls[0][1]; const requestBody = JSON.parse(callArgs?.body as string); expect(requestBody).toEqual({ output_format: 'png', safety_tolerance: 6, prompt: 'Create variation of these images', input_image: 'base64image1', input_image_2: 'base64image2', input_image_3: 'base64image3', input_image_4: 'base64image4', }); expect(requestBody).not.toHaveProperty('input_image_5'); }); }); describe('Model endpoint mapping', () => { it('should map models to correct endpoints', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValue({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const testCases = [ { model: 'flux-dev', endpoint: '/v1/flux-dev' }, { model: 'flux-pro', endpoint: '/v1/flux-pro' }, { model: 'flux-pro-1.1', endpoint: '/v1/flux-pro-1.1' }, { model: 'flux-pro-1.1-ultra', endpoint: '/v1/flux-pro-1.1-ultra' }, { model: 'flux-kontext-pro', endpoint: '/v1/flux-kontext-pro' }, { model: 'flux-kontext-max', endpoint: '/v1/flux-kontext-max' }, ]; // Act & Assert for (const { model, endpoint } of testCases) { vi.clearAllMocks(); const payload: CreateImagePayload = { model, params: { prompt: `Test image for ${model}`, }, }; await createBflImage(payload, mockOptions); expect(mockFetch).toHaveBeenCalledWith(`https://api.bfl.ai${endpoint}`, expect.any(Object)); } }); it('should throw error for unsupported model', async () => { // Arrange const payload: CreateImagePayload = { model: 'unsupported-model', params: { prompt: 'Test image', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.objectContaining({ message: 'Unsupported BFL model: unsupported-model', }), errorType: 'ModelNotFound', provider: 'bfl', }); }); it('should use custom baseURL when provided', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://custom-api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockResolvedValue({ imageUrl: 'https://example.com/result.jpg', }); const customOptions = { ...mockOptions, baseURL: 'https://custom-api.bfl.ai', }; const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test with custom URL', }, }; // Act await createBflImage(payload, customOptions); // Assert expect(mockFetch).toHaveBeenCalledWith( 'https://custom-api.bfl.ai/v1/flux-dev', expect.any(Object), ); }); }); describe('Status handling', () => { it('should return success when status is Ready with result', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); // Mock the asyncifyPolling to call checkStatus with Ready status mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => { const result = checkStatus({ id: 'task-123', status: BflStatusResponse.Ready, result: { sample: 'https://example.com/generated-image.jpg', }, }); if (result.status === 'success') { return result.data; } throw result.error; }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test successful generation', }, }; // Act const result = await createBflImage(payload, mockOptions); // Assert expect(result).toEqual({ imageUrl: 'https://example.com/generated-image.jpg', }); }); it('should throw error when status is Ready but no result', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => { const result = checkStatus({ id: 'task-123', status: BflStatusResponse.Ready, result: null, }); if (result.status === 'success') { return result.data; } throw result.error; }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test no result error', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); }); it('should handle error statuses', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); const errorStatuses = [ BflStatusResponse.Error, BflStatusResponse.ContentModerated, BflStatusResponse.RequestModerated, ]; for (const status of errorStatuses) { mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => { const result = checkStatus({ id: 'task-123', status, details: { error: 'Test error details' }, }); if (result.status === 'success') { return result.data; } throw result.error; }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: `Test ${status} error`, }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); } }); it('should handle TaskNotFound status', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => { const result = checkStatus({ id: 'task-123', status: BflStatusResponse.TaskNotFound, }); if (result.status === 'success') { return result.data; } throw result.error; }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test task not found', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); }); it('should continue polling for Pending status', async () => { // Arrange const { asyncifyPolling } = await import('../../utils/asyncifyPolling'); const mockAsyncifyPolling = vi.mocked(asyncifyPolling); mockFetch.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: 'task-123', polling_url: 'https://api.bfl.ai/v1/get_result?id=task-123', }), } as Response); mockAsyncifyPolling.mockImplementation(async ({ checkStatus }) => { // First call - Pending status const pendingResult = checkStatus({ id: 'task-123', status: BflStatusResponse.Pending, }); expect(pendingResult.status).toBe('pending'); // Simulate successful completion const successResult = checkStatus({ id: 'task-123', status: BflStatusResponse.Ready, result: { sample: 'https://example.com/generated-image.jpg', }, }); return successResult.data; }); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test pending status', }, }; // Act const result = await createBflImage(payload, mockOptions); // Assert expect(result).toEqual({ imageUrl: 'https://example.com/generated-image.jpg', }); }); }); describe('Error handling', () => { it('should handle fetch errors during task submission', async () => { // Arrange mockFetch.mockRejectedValue(new Error('Network error')); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test network error', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toThrow(); }); it('should handle HTTP error responses', async () => { // Arrange mockFetch.mockResolvedValueOnce({ ok: false, status: 400, statusText: 'Bad Request', json: () => Promise.resolve({ detail: [{ msg: 'Invalid prompt' }], }), } as Response); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test HTTP error', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); }); it('should handle HTTP error responses without detail', async () => { // Arrange mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', json: () => Promise.resolve({}), } as Response); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test HTTP error without detail', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); }); it('should handle non-JSON error responses', async () => { // Arrange mockFetch.mockResolvedValueOnce({ ok: false, status: 500, statusText: 'Internal Server Error', json: () => Promise.reject(new Error('Invalid JSON')), } as Response); const payload: CreateImagePayload = { model: 'flux-dev', params: { prompt: 'Test non-JSON error', }, }; // Act & Assert await expect(createBflImage(payload, mockOptions)).rejects.toMatchObject({ error: expect.any(Object), errorType: 'ProviderBizError', provider: 'bfl', }); }); }); });