import { beforeEach, describe, expect, it, vi } from 'vitest' const googleGenerateContentMock = vi.hoisted(() => vi.fn()) const getProviderConfigMock = vi.hoisted(() => vi.fn()) const getImageBase64CachedMock = vi.hoisted(() => vi.fn(async () => 'data:image/png;base64,UkVG')) const arkImageGenerationMock = vi.hoisted(() => vi.fn()) const normalizeToBase64ForGenerationMock = vi.hoisted(() => vi.fn(async () => 'UkVG')) vi.mock('@google/genai', () => ({ GoogleGenAI: class GoogleGenAI { models = { generateContent: googleGenerateContentMock, } }, HarmCategory: { HARM_CATEGORY_HARASSMENT: 'HARM_CATEGORY_HARASSMENT', HARM_CATEGORY_HATE_SPEECH: 'HARM_CATEGORY_HATE_SPEECH', HARM_CATEGORY_SEXUALLY_EXPLICIT: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', HARM_CATEGORY_DANGEROUS_CONTENT: 'HARM_CATEGORY_DANGEROUS_CONTENT', }, HarmBlockThreshold: { BLOCK_NONE: 'BLOCK_NONE', }, })) vi.mock('@/lib/api-config', () => ({ getProviderConfig: getProviderConfigMock, })) vi.mock('@/lib/image-cache', () => ({ getImageBase64Cached: getImageBase64CachedMock, })) vi.mock('@/lib/ark-api', () => ({ arkImageGeneration: arkImageGenerationMock, })) vi.mock('@/lib/media/outbound-image', () => ({ normalizeToBase64ForGeneration: normalizeToBase64ForGenerationMock, })) import { ArkSeedreamGenerator } from '@/lib/generators/ark' import { GeminiCompatibleImageGenerator } from '@/lib/generators/image/gemini-compatible' import { GoogleGeminiImageGenerator } from '@/lib/generators/image/google' describe('image provider smoke tests', () => { beforeEach(() => { vi.clearAllMocks() }) it('Google Gemini 官方文生图可用 -> 返回 data URL', async () => { getProviderConfigMock.mockResolvedValueOnce({ id: 'google', apiKey: 'google-key', }) googleGenerateContentMock.mockResolvedValueOnce({ candidates: [ { content: { parts: [ { inlineData: { mimeType: 'image/png', data: 'R09PR0xF', }, }, ], }, }, ], }) const generator = new GoogleGeminiImageGenerator('gemini-3-pro-image-preview') const result = await generator.generate({ userId: 'user-1', prompt: 'draw a mountain', options: { aspectRatio: '3:4', }, }) expect(result).toEqual({ success: true, imageBase64: 'R09PR0xF', imageUrl: 'data:image/png;base64,R09PR0xF', }) expect(googleGenerateContentMock).toHaveBeenCalledWith({ model: 'gemini-3-pro-image-preview', contents: [{ parts: [{ text: 'draw a mountain' }] }], config: expect.objectContaining({ responseModalities: ['TEXT', 'IMAGE'], imageConfig: { aspectRatio: '3:4' }, }), }) }) it('Seedream 图生图可用 -> 返回 ARK 图片 URL', async () => { getProviderConfigMock.mockResolvedValueOnce({ id: 'ark', apiKey: 'ark-key', }) arkImageGenerationMock.mockResolvedValueOnce({ data: [{ url: 'https://seedream.test/image.png' }], }) const generator = new ArkSeedreamGenerator() const result = await generator.generate({ userId: 'user-1', prompt: 'refine this style', referenceImages: ['https://example.com/ref.png'], options: { modelId: 'doubao-seedream-4-5-251128', aspectRatio: '3:4', }, }) expect(result).toEqual({ success: true, imageUrl: 'https://seedream.test/image.png', }) expect(arkImageGenerationMock).toHaveBeenCalledWith({ model: 'doubao-seedream-4-5-251128', prompt: 'refine this style', sequential_image_generation: 'disabled', response_format: 'url', stream: false, watermark: false, size: '3544x4728', image: ['UkVG'], }, { apiKey: 'ark-key', logPrefix: '[ARK Image]', }) }) it('Seedream 返回多图时 -> 同时返回 imageUrl 和 imageUrls', async () => { getProviderConfigMock.mockResolvedValueOnce({ id: 'ark', apiKey: 'ark-key', }) arkImageGenerationMock.mockResolvedValueOnce({ data: [ { url: 'https://seedream.test/image-1.png' }, { url: 'https://seedream.test/image-2.png' }, ], }) const generator = new ArkSeedreamGenerator() const result = await generator.generate({ userId: 'user-1', prompt: 'refine this style', referenceImages: ['https://example.com/ref.png'], options: { modelId: 'doubao-seedream-4-5-251128', aspectRatio: '3:4', }, }) expect(result).toEqual({ success: true, imageUrl: 'https://seedream.test/image-1.png', imageUrls: ['https://seedream.test/image-1.png', 'https://seedream.test/image-2.png'], }) }) it('Gemini 兼容层文生图可用 -> 直连 Gemini SDK 协议返回图片', async () => { getProviderConfigMock.mockResolvedValueOnce({ id: 'gemini-compatible:gm-1', apiKey: 'gm-key', baseUrl: 'https://gm.test', }) googleGenerateContentMock.mockResolvedValueOnce({ candidates: [ { content: { parts: [ { inlineData: { mimeType: 'image/webp', data: 'R01fVEVYVA==', }, }, ], }, }, ], }) const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1') const result = await generator.generate({ userId: 'user-1', prompt: 'draw a cat', options: { aspectRatio: '1:1', }, }) expect(result).toEqual({ success: true, imageBase64: 'R01fVEVYVA==', imageUrl: 'data:image/webp;base64,R01fVEVYVA==', }) expect(googleGenerateContentMock).toHaveBeenCalledWith({ model: 'gemini-2.5-flash-image-preview', contents: [{ parts: [{ text: 'draw a cat' }] }], config: expect.objectContaining({ responseModalities: ['TEXT', 'IMAGE'], imageConfig: { aspectRatio: '1:1' }, }), }) }) it('Gemini 兼容层图生图可用 -> 参考图会注入 inlineData', async () => { getProviderConfigMock.mockResolvedValueOnce({ id: 'gemini-compatible:gm-1', apiKey: 'gm-key', baseUrl: 'https://gm.test', }) googleGenerateContentMock.mockResolvedValueOnce({ candidates: [ { content: { parts: [ { inlineData: { mimeType: 'image/png', data: 'R01fSTJJPQ==', }, }, ], }, }, ], }) const generator = new GeminiCompatibleImageGenerator('gemini-2.5-flash-image-preview', 'gemini-compatible:gm-1') const result = await generator.generate({ userId: 'user-1', prompt: 'restyle this portrait', referenceImages: ['/api/files/ref-image'], options: { resolution: '2K', }, }) expect(result).toEqual({ success: true, imageBase64: 'R01fSTJJPQ==', imageUrl: 'data:image/png;base64,R01fSTJJPQ==', }) const call = googleGenerateContentMock.mock.calls[0] expect(call).toBeTruthy() if (!call) { throw new Error('Gemini generateContent should be called') } const content = call[0] as { contents: Array<{ parts: Array<{ inlineData?: { mimeType: string; data: string }; text?: string }> }> config: { imageConfig?: { imageSize?: string } } } expect(content.contents[0].parts[0].inlineData).toEqual({ mimeType: 'image/png', data: 'UkVG' }) expect(content.contents[0].parts[1].text).toBe('restyle this portrait') expect(content.config.imageConfig).toEqual({ imageSize: '2K' }) }) })