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.

884 lines (731 loc) • 33.4 kB
import debug from 'debug'; import { sha256 } from 'js-sha256'; import mime from 'mime'; import { nanoid } from 'nanoid'; import sharp from 'sharp'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { FileService } from '@/server/services/file'; import { calculateThumbnailDimensions } from '@/utils/number'; import { getYYYYmmddHHMMss } from '@/utils/time'; import { inferFileExtensionFromImageUrl } from '@/utils/url'; import { GenerationService, fetchImageFromUrl } from './index'; // Mock fetch globally const mockFetch = vi.fn(); global.fetch = mockFetch; vi.mock('debug', () => ({ default: () => vi.fn(), })); vi.mock('js-sha256'); vi.mock('mime'); vi.mock('nanoid'); vi.mock('sharp'); vi.mock('@/server/services/file'); vi.mock('@/utils/number'); vi.mock('@/utils/time'); vi.mock('@/utils/url'); describe('GenerationService', () => { let service: GenerationService; const mockDb = {} as any; const mockUserId = 'test-user'; let mockFileService: any; beforeEach(() => { vi.clearAllMocks(); // Setup common mocks used across all tests mockFileService = { uploadMedia: vi.fn(), }; vi.mocked(FileService).mockImplementation(() => mockFileService); vi.mocked(nanoid).mockReturnValue('test-uuid'); vi.mocked(getYYYYmmddHHMMss).mockReturnValue('20240101123000'); // Setup mime.getExtension with consistent behavior vi.mocked(mime.getExtension).mockImplementation((mimeType) => { const extensions = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/gif': 'gif', 'image/unknown': null, }; return extensions[mimeType as keyof typeof extensions] || 'png'; }); // Setup inferFileExtensionFromImageUrl with consistent behavior vi.mocked(inferFileExtensionFromImageUrl).mockImplementation((url) => { if (url.includes('.jpg')) return 'jpg'; if (url.includes('.gif')) return 'gif'; if (url.includes('image') && !url.includes('.')) return ''; // For error testing return 'png'; }); service = new GenerationService(mockDb, mockUserId); }); afterEach(() => { vi.restoreAllMocks(); }); describe('fetchImageFromUrl', () => { // Note: Using global beforeEach/afterEach from parent describe for consistency describe('base64 data URI', () => { it('should extract buffer and MIME type from base64 data URI', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const result = await fetchImageFromUrl(dataUri); expect(result.mimeType).toBe('image/png'); expect(result.buffer).toBeInstanceOf(Buffer); expect(result.buffer.length).toBeGreaterThan(0); expect(Buffer.from(base64Data, 'base64').equals(result.buffer)).toBe(true); }); it('should handle different MIME types in base64 data URI', async () => { const base64Data = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'; const dataUri = `data:image/gif;base64,${base64Data}`; const result = await fetchImageFromUrl(dataUri); expect(result.mimeType).toBe('image/gif'); expect(result.buffer).toBeInstanceOf(Buffer); }); it('should handle base64 data URI with additional parameters', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;charset=utf-8;base64,${base64Data}`; // This should fail because parseDataUri only supports the strict format: data:mime/type;base64,data await expect(fetchImageFromUrl(dataUri)).rejects.toThrow( 'Invalid data URI format: data:image/png;charset=utf-8;base64,', ); }); }); describe('HTTP URL', () => { it('should fetch image from HTTP URL successfully', async () => { const mockBuffer = Buffer.from('mock image data'); const mockArrayBuffer = mockBuffer.buffer.slice( mockBuffer.byteOffset, mockBuffer.byteOffset + mockBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('image/jpeg'), }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const result = await fetchImageFromUrl('https://example.com/image.jpg'); expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg', { headers: undefined, }); expect(result.mimeType).toBe('image/jpeg'); expect(result.buffer).toBeInstanceOf(Buffer); expect(result.buffer.equals(mockBuffer)).toBe(true); }); it('should fetch image with custom fetchHeaders', async () => { const mockBuffer = Buffer.from('mock image data'); const mockArrayBuffer = mockBuffer.buffer.slice( mockBuffer.byteOffset, mockBuffer.byteOffset + mockBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('image/jpeg'), }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const customHeaders = { 'Authorization': 'Bearer token123', 'X-API-Key': 'api-key-456', }; const result = await fetchImageFromUrl('https://example.com/image.jpg', customHeaders); expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg', { headers: customHeaders, }); expect(result.mimeType).toBe('image/jpeg'); expect(result.buffer).toBeInstanceOf(Buffer); expect(result.buffer.equals(mockBuffer)).toBe(true); }); it('should handle missing content-type header', async () => { const mockBuffer = Buffer.from('mock image data'); const mockArrayBuffer = mockBuffer.buffer.slice( mockBuffer.byteOffset, mockBuffer.byteOffset + mockBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue(null), // No content-type header }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const result = await fetchImageFromUrl('https://example.com/image.jpg'); expect(result.mimeType).toBe('application/octet-stream'); expect(result.buffer).toBeInstanceOf(Buffer); }); it('should throw error when fetch fails', async () => { mockFetch.mockResolvedValueOnce({ ok: false, status: 404, statusText: 'Not Found', }); await expect(fetchImageFromUrl('https://example.com/nonexistent.jpg')).rejects.toThrow( 'Failed to fetch image from https://example.com/nonexistent.jpg: 404 Not Found', ); expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg', { headers: undefined, }); }); it('should throw error when network request fails', async () => { mockFetch.mockRejectedValueOnce(new Error('Network error')); await expect(fetchImageFromUrl('https://example.com/image.jpg')).rejects.toThrow( 'Network error', ); }); }); describe('edge cases', () => { it('should handle base64 data URI correctly', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const result = await fetchImageFromUrl(dataUri); expect(result.mimeType).toBe('image/png'); expect(result.buffer).toBeInstanceOf(Buffer); }); it('should throw error for invalid data URI format', async () => { const invalidDataUri = 'data:image/png:invalid-format'; await expect(fetchImageFromUrl(invalidDataUri)).rejects.toThrow( 'Invalid data URI format: data:image/png:invalid-format', ); }); it('should throw error for malformed data URI without base64', async () => { const malformedDataUri = 'data:image/png;charset=utf-8,not-base64-data'; await expect(fetchImageFromUrl(malformedDataUri)).rejects.toThrow( 'Invalid data URI format: data:image/png;charset=utf-8,not-base64-data', ); }); it('should handle different URL schemes', async () => { const mockBuffer = Buffer.from('mock image data'); const mockArrayBuffer = mockBuffer.buffer.slice( mockBuffer.byteOffset, mockBuffer.byteOffset + mockBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('image/png'), }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const result = await fetchImageFromUrl('http://example.com/image.png'); expect(result.mimeType).toBe('image/png'); expect(result.buffer).toBeInstanceOf(Buffer); }); }); describe('return type validation', () => { it('should return object with correct structure', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const result = await fetchImageFromUrl(dataUri); expect(result).toHaveProperty('buffer'); expect(result).toHaveProperty('mimeType'); expect(typeof result.mimeType).toBe('string'); expect(result.buffer).toBeInstanceOf(Buffer); }); }); }); describe('transformImageForGeneration', () => { const mockOriginalBuffer = Buffer.from('original image data'); const mockThumbnailBuffer = Buffer.from('thumbnail image data'); beforeEach(() => { // Reset and configure sha256 with stable implementation vi.mocked(sha256) .mockReset() .mockImplementation( (buffer: any) => `hash-${buffer.length}-${buffer.slice(0, 4).toString('hex')}`, ); }); it('should transform base64 image successfully', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 512, thumbnailHeight: 384, }); const result = await service.transformImageForGeneration(dataUri); // Verify image properties expect(result.image.width).toBe(800); expect(result.image.height).toBe(600); expect(result.image.extension).toBe('png'); expect(result.image.hash).toMatch(/^hash-\d+-/); // Matches our stable hash format // Verify thumbnail properties expect(result.thumbnailImage.width).toBe(512); expect(result.thumbnailImage.height).toBe(384); expect(result.thumbnailImage.hash).toMatch(/^hash-\d+-/); // Verify resize was called with correct dimensions expect(mockSharp.resize).toHaveBeenCalledWith(512, 384); expect(mockSharp.resize).toHaveBeenCalledTimes(1); // Verify sha256 was called twice (for original and thumbnail) expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2); }); it('should handle HTTP URL successfully', async () => { const url = 'https://example.com/image.jpg'; // Mock fetch for HTTP URL const mockArrayBuffer = mockOriginalBuffer.buffer.slice( mockOriginalBuffer.byteOffset, mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('image/jpeg'), }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 1024, height: 768 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 512, thumbnailHeight: 384, }); const result = await service.transformImageForGeneration(url); expect(result.image.width).toBe(1024); expect(result.image.height).toBe(768); expect(result.image.extension).toBe('jpg'); // URL is image.jpg, so extension should be jpg expect(result.thumbnailImage.width).toBe(512); expect(result.thumbnailImage.height).toBe(384); }); it('should handle images that do not need resizing', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 256, height: 256 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: false, thumbnailWidth: 256, thumbnailHeight: 256, }); const result = await service.transformImageForGeneration(dataUri); // When no resizing is needed but format is not webp, thumbnail is still processed for format conversion const expectedBuffer = Buffer.from(base64Data, 'base64'); expect(result.image.buffer).toEqual(expectedBuffer); // Thumbnail buffer will be different because it's converted to WebP even without resizing expect(result.thumbnailImage.buffer).toEqual(mockThumbnailBuffer); // Resize is called with original dimensions for format conversion expect(mockSharp.resize).toHaveBeenCalledWith(256, 256); }); it('should throw error for invalid image format', async () => { const dataUri = 'data:image/png;base64,invalid-data'; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: null, height: null }), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow( 'Invalid image format: png, url: data:image/png;base64,invalid-data', ); }); it('should throw error when unable to determine extension from MIME type', async () => { const dataUri = 'data:image/unknown;base64,some-data'; vi.mocked(mime.getExtension).mockReturnValue(null); const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'unknown', width: 100, height: 100 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: false, thumbnailWidth: 100, thumbnailHeight: 100, }); await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow( 'Unable to determine file extension for MIME type: image/unknown', ); }); it('should throw error when unable to determine extension from URL', async () => { const url = 'https://example.com/image'; vi.mocked(inferFileExtensionFromImageUrl).mockReturnValue(''); // Mock fetch for HTTP URL - return a MIME type that can't be resolved to extension const mockArrayBuffer = mockOriginalBuffer.buffer.slice( mockOriginalBuffer.byteOffset, mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('application/octet-stream'), // Changed to unresolvable MIME type }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 100, height: 100 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: false, thumbnailWidth: 100, thumbnailHeight: 100, }); await expect(service.transformImageForGeneration(url)).rejects.toThrow( 'Unable to determine file extension from URL: https://example.com/image', ); }); it('should handle sharp processing error', async () => { const dataUri = 'data:image/png;base64,invalid-data'; const mockSharp = { metadata: vi.fn().mockRejectedValue(new Error('Invalid image data')), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow( 'Invalid image data', ); }); it('should handle sharp resize error', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockRejectedValue(new Error('Sharp processing failed')), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 512, thumbnailHeight: 384, }); await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow( 'Sharp processing failed', ); }); it('should validate resize dimensions are called correctly', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1024, height: 768 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 400, thumbnailHeight: 300, }); await service.transformImageForGeneration(dataUri); // Verify resize was called with exact calculated dimensions expect(mockSharp.resize).toHaveBeenCalledWith(400, 300); expect(mockSharp.resize).toHaveBeenCalledTimes(1); // Verify calculateThumbnailDimensions was called with original dimensions expect(calculateThumbnailDimensions).toHaveBeenCalledWith(1024, 768); }); it('should validate file naming pattern includes correct dimensions', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1920, height: 1080 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 512, thumbnailHeight: 288, }); const result = await service.transformImageForGeneration(dataUri); // Verify original image dimensions are preserved expect(result.image.width).toBe(1920); expect(result.image.height).toBe(1080); // Verify thumbnail dimensions match calculation expect(result.thumbnailImage.width).toBe(512); expect(result.thumbnailImage.height).toBe(288); // Verify proper extensions - image keeps original, thumbnail becomes webp expect(result.image.extension).toBe('png'); expect(result.thumbnailImage.extension).toBe('webp'); }); it('should verify sha256 is called exactly twice for transformations', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/png;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 512, thumbnailHeight: 384, }); await service.transformImageForGeneration(dataUri); // Should call sha256 exactly twice: once for original, once for thumbnail expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2); // Verify it's called with Buffer instances const calls = vi.mocked(sha256).mock.calls; expect(calls[0][0]).toBeInstanceOf(Buffer); // Original image buffer expect(calls[1][0]).toBeInstanceOf(Buffer); // Thumbnail buffer }); }); describe('uploadImageForGeneration', () => { const mockImage = { buffer: Buffer.from('image data'), extension: 'png', hash: 'image-hash', height: 800, mime: 'image/png', size: 1000, width: 600, }; const mockThumbnail = { buffer: Buffer.from('thumbnail data'), extension: 'png', hash: 'thumbnail-hash', height: 400, mime: 'image/png', size: 500, width: 300, }; it('should upload both images when buffers are different', async () => { mockFileService.uploadMedia .mockResolvedValueOnce({ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png', }) .mockResolvedValueOnce({ key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png', }); const result = await service.uploadImageForGeneration(mockImage, mockThumbnail); expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2); // Verify correct file naming pattern with dimensions expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith( 1, expect.stringMatching(/^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/), mockImage.buffer, ); expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith( 2, expect.stringMatching(/^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/), mockThumbnail.buffer, ); expect(result).toEqual({ imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png', thumbnailImageUrl: 'generations/images/test-uuid_300x400_20240101123000_thumb.png', }); }); it('should upload single image when buffers are identical', async () => { const identicalBuffer = Buffer.from('same data'); const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer }; const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer }; mockFileService.uploadMedia.mockResolvedValueOnce({ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png', }); const result = await service.uploadImageForGeneration( imageWithSameBuffer, thumbnailWithSameBuffer, ); expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1); expect(mockFileService.uploadMedia).toHaveBeenCalledWith( 'generations/images/test-uuid_600x800_20240101123000_raw.png', identicalBuffer, ); expect(result).toEqual({ imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png', thumbnailImageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png', }); }); it('should handle partial upload failure in concurrent uploads', async () => { mockFileService.uploadMedia .mockResolvedValueOnce({ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png', }) .mockRejectedValueOnce(new Error('Thumbnail upload failed')); await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow( 'Thumbnail upload failed', ); // Verify both uploads were attempted expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2); }); it('should handle complete upload failure', async () => { mockFileService.uploadMedia .mockRejectedValueOnce(new Error('Image upload failed')) .mockRejectedValueOnce(new Error('Thumbnail upload failed')); await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow( 'Image upload failed', ); // Should fail fast on first rejection expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2); }); it('should handle single image upload failure', async () => { const identicalBuffer = Buffer.from('same data'); const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer }; const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer }; mockFileService.uploadMedia.mockRejectedValueOnce(new Error('Upload service unavailable')); await expect( service.uploadImageForGeneration(imageWithSameBuffer, thumbnailWithSameBuffer), ).rejects.toThrow('Upload service unavailable'); expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1); }); it('should validate file naming format with correct patterns', async () => { mockFileService.uploadMedia .mockResolvedValueOnce({ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png', }) .mockResolvedValueOnce({ key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png', }); await service.uploadImageForGeneration(mockImage, mockThumbnail); // Verify file name patterns match exact format: {uuid}_{width}x{height}_{timestamp}_{type}.{ext} const imageCall = mockFileService.uploadMedia.mock.calls[0]; const thumbnailCall = mockFileService.uploadMedia.mock.calls[1]; expect(imageCall[0]).toMatch( /^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/, ); expect(thumbnailCall[0]).toMatch( /^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/, ); // Verify dimensions are correctly embedded in filename expect(imageCall[0]).toContain('600x800'); // Original dimensions expect(thumbnailCall[0]).toContain('300x400'); // Thumbnail dimensions // Verify file type suffixes expect(imageCall[0]).toContain('_raw.'); expect(thumbnailCall[0]).toContain('_thumb.'); }); }); describe('createCoverFromUrl', () => { const mockCoverBuffer = Buffer.from('cover image data'); // Note: Using global mock configuration from parent describe it('should create cover from base64 data URI', async () => { const base64Data = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg=='; const dataUri = `data:image/jpeg;base64,${base64Data}`; const mockSharp = { metadata: vi.fn().mockResolvedValue({ width: 512, height: 384 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 256, thumbnailHeight: 192, }); mockFileService.uploadMedia.mockResolvedValueOnce({ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp', }); const result = await service.createCoverFromUrl(dataUri); expect(mockSharp.resize).toHaveBeenCalledWith(256, 192); expect(mockFileService.uploadMedia).toHaveBeenCalledWith( 'generations/covers/test-uuid_256x192_20240101123000_cover.webp', mockCoverBuffer, ); expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp'); }); it('should create cover from HTTP URL', async () => { const url = 'https://example.com/image.jpg'; // Mock fetch for HTTP URL const mockBuffer = Buffer.from('original image data'); const mockArrayBuffer = mockBuffer.buffer.slice( mockBuffer.byteOffset, mockBuffer.byteOffset + mockBuffer.byteLength, ); mockFetch.mockResolvedValueOnce({ ok: true, status: 200, headers: { get: vi.fn().mockReturnValue('image/jpeg'), }, arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer), }); const mockSharp = { metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 256, thumbnailHeight: 192, }); mockFileService.uploadMedia.mockResolvedValueOnce({ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp', }); const result = await service.createCoverFromUrl(url); expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp'); }); it('should throw error for invalid image format', async () => { const dataUri = 'data:image/png;base64,invalid-data'; const mockSharp = { metadata: vi.fn().mockResolvedValue({ width: null, height: null }), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); await expect(service.createCoverFromUrl(dataUri)).rejects.toThrow( 'Invalid image format for cover creation', ); }); it('should validate cover file naming format includes dimensions', async () => { const dataUri = 'data:image/jpeg;base64,some-data'; const mockSharp = { metadata: vi.fn().mockResolvedValue({ width: 1024, height: 768 }), resize: vi.fn().mockReturnThis(), webp: vi.fn().mockReturnThis(), toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer), }; vi.mocked(sharp).mockReturnValue(mockSharp as any); vi.mocked(calculateThumbnailDimensions).mockReturnValue({ shouldResize: true, thumbnailWidth: 256, thumbnailHeight: 192, }); mockFileService.uploadMedia.mockResolvedValueOnce({ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp', }); const result = await service.createCoverFromUrl(dataUri); // Verify cover filename contains calculated dimensions expect(mockFileService.uploadMedia).toHaveBeenCalledWith( 'generations/covers/test-uuid_256x192_20240101123000_cover.webp', mockCoverBuffer, ); // Verify filename pattern: {uuid}_{width}x{height}_{timestamp}_cover.{ext} const filename = mockFileService.uploadMedia.mock.calls[0][0]; expect(filename).toMatch( /^generations\/covers\/test-uuid_256x192_20240101123000_cover\.webp$/, ); expect(filename).toContain('256x192'); // Cover dimensions expect(filename).toContain('_cover.'); // Cover suffix expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp'); }); }); });