@lobehub/chat 1.104.0 → 1.104.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.
Files changed (23) hide show
  1. package/.cursor/rules/code-review.mdc +2 -0
  2. package/.cursor/rules/typescript.mdc +3 -1
  3. package/CHANGELOG.md +25 -0
  4. package/changelog/v1.json +9 -0
  5. package/package.json +1 -1
  6. package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +6 -1
  7. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +3 -2
  8. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +27 -24
  9. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +14 -3
  10. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +4 -7
  11. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +3 -0
  12. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.test.ts +600 -0
  13. package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +126 -7
  14. package/src/const/imageGeneration.ts +18 -0
  15. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +3 -0
  16. package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +7 -5
  17. package/src/libs/model-runtime/utils/streams/openai/openai.ts +8 -4
  18. package/src/libs/model-runtime/utils/usageConverter.test.ts +45 -1
  19. package/src/libs/model-runtime/utils/usageConverter.ts +6 -2
  20. package/src/server/services/generation/index.test.ts +848 -0
  21. package/src/server/services/generation/index.ts +90 -69
  22. package/src/utils/number.test.ts +101 -1
  23. package/src/utils/number.ts +42 -0
@@ -0,0 +1,848 @@
1
+ import debug from 'debug';
2
+ import { sha256 } from 'js-sha256';
3
+ import mime from 'mime';
4
+ import { nanoid } from 'nanoid';
5
+ import sharp from 'sharp';
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
7
+
8
+ import { FileService } from '@/server/services/file';
9
+ import { calculateThumbnailDimensions } from '@/utils/number';
10
+ import { getYYYYmmddHHMMss } from '@/utils/time';
11
+ import { inferFileExtensionFromImageUrl } from '@/utils/url';
12
+
13
+ import { GenerationService, fetchImageFromUrl } from './index';
14
+
15
+ // Mock fetch globally
16
+ const mockFetch = vi.fn();
17
+ global.fetch = mockFetch;
18
+
19
+ vi.mock('debug', () => ({
20
+ default: () => vi.fn(),
21
+ }));
22
+ vi.mock('js-sha256');
23
+ vi.mock('mime');
24
+ vi.mock('nanoid');
25
+ vi.mock('sharp');
26
+ vi.mock('@/server/services/file');
27
+ vi.mock('@/utils/number');
28
+ vi.mock('@/utils/time');
29
+ vi.mock('@/utils/url');
30
+
31
+ describe('GenerationService', () => {
32
+ let service: GenerationService;
33
+ const mockDb = {} as any;
34
+ const mockUserId = 'test-user';
35
+ let mockFileService: any;
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+
40
+ // Setup common mocks used across all tests
41
+ mockFileService = {
42
+ uploadMedia: vi.fn(),
43
+ };
44
+ vi.mocked(FileService).mockImplementation(() => mockFileService);
45
+ vi.mocked(nanoid).mockReturnValue('test-uuid');
46
+ vi.mocked(getYYYYmmddHHMMss).mockReturnValue('20240101123000');
47
+
48
+ // Setup mime.getExtension with consistent behavior
49
+ vi.mocked(mime.getExtension).mockImplementation((mimeType) => {
50
+ const extensions = {
51
+ 'image/png': 'png',
52
+ 'image/jpeg': 'jpg',
53
+ 'image/gif': 'gif',
54
+ 'image/unknown': null,
55
+ };
56
+ return extensions[mimeType as keyof typeof extensions] || 'png';
57
+ });
58
+
59
+ // Setup inferFileExtensionFromImageUrl with consistent behavior
60
+ vi.mocked(inferFileExtensionFromImageUrl).mockImplementation((url) => {
61
+ if (url.includes('.jpg')) return 'jpg';
62
+ if (url.includes('.gif')) return 'gif';
63
+ if (url.includes('image') && !url.includes('.')) return ''; // For error testing
64
+ return 'png';
65
+ });
66
+
67
+ service = new GenerationService(mockDb, mockUserId);
68
+ });
69
+
70
+ afterEach(() => {
71
+ vi.restoreAllMocks();
72
+ });
73
+
74
+ describe('fetchImageFromUrl', () => {
75
+ // Note: Using global beforeEach/afterEach from parent describe for consistency
76
+
77
+ describe('base64 data URI', () => {
78
+ it('should extract buffer and MIME type from base64 data URI', async () => {
79
+ const base64Data =
80
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
81
+ const dataUri = `data:image/png;base64,${base64Data}`;
82
+
83
+ const result = await fetchImageFromUrl(dataUri);
84
+
85
+ expect(result.mimeType).toBe('image/png');
86
+ expect(result.buffer).toBeInstanceOf(Buffer);
87
+ expect(result.buffer.length).toBeGreaterThan(0);
88
+ expect(Buffer.from(base64Data, 'base64').equals(result.buffer)).toBe(true);
89
+ });
90
+
91
+ it('should handle different MIME types in base64 data URI', async () => {
92
+ const base64Data = 'R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
93
+ const dataUri = `data:image/gif;base64,${base64Data}`;
94
+
95
+ const result = await fetchImageFromUrl(dataUri);
96
+
97
+ expect(result.mimeType).toBe('image/gif');
98
+ expect(result.buffer).toBeInstanceOf(Buffer);
99
+ });
100
+
101
+ it('should handle base64 data URI with additional parameters', async () => {
102
+ const base64Data =
103
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
104
+ const dataUri = `data:image/png;charset=utf-8;base64,${base64Data}`;
105
+
106
+ // This should fail because parseDataUri only supports the strict format: data:mime/type;base64,data
107
+ await expect(fetchImageFromUrl(dataUri)).rejects.toThrow(
108
+ 'Invalid data URI format: data:image/png;charset=utf-8;base64,',
109
+ );
110
+ });
111
+ });
112
+
113
+ describe('HTTP URL', () => {
114
+ it('should fetch image from HTTP URL successfully', async () => {
115
+ const mockBuffer = Buffer.from('mock image data');
116
+ const mockArrayBuffer = mockBuffer.buffer.slice(
117
+ mockBuffer.byteOffset,
118
+ mockBuffer.byteOffset + mockBuffer.byteLength,
119
+ );
120
+
121
+ mockFetch.mockResolvedValueOnce({
122
+ ok: true,
123
+ status: 200,
124
+ headers: {
125
+ get: vi.fn().mockReturnValue('image/jpeg'),
126
+ },
127
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
128
+ });
129
+
130
+ const result = await fetchImageFromUrl('https://example.com/image.jpg');
131
+
132
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/image.jpg');
133
+ expect(result.mimeType).toBe('image/jpeg');
134
+ expect(result.buffer).toBeInstanceOf(Buffer);
135
+ expect(result.buffer.equals(mockBuffer)).toBe(true);
136
+ });
137
+
138
+ it('should handle missing content-type header', async () => {
139
+ const mockBuffer = Buffer.from('mock image data');
140
+ const mockArrayBuffer = mockBuffer.buffer.slice(
141
+ mockBuffer.byteOffset,
142
+ mockBuffer.byteOffset + mockBuffer.byteLength,
143
+ );
144
+
145
+ mockFetch.mockResolvedValueOnce({
146
+ ok: true,
147
+ status: 200,
148
+ headers: {
149
+ get: vi.fn().mockReturnValue(null), // No content-type header
150
+ },
151
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
152
+ });
153
+
154
+ const result = await fetchImageFromUrl('https://example.com/image.jpg');
155
+
156
+ expect(result.mimeType).toBe('application/octet-stream');
157
+ expect(result.buffer).toBeInstanceOf(Buffer);
158
+ });
159
+
160
+ it('should throw error when fetch fails', async () => {
161
+ mockFetch.mockResolvedValueOnce({
162
+ ok: false,
163
+ status: 404,
164
+ statusText: 'Not Found',
165
+ });
166
+
167
+ await expect(fetchImageFromUrl('https://example.com/nonexistent.jpg')).rejects.toThrow(
168
+ 'Failed to fetch image from https://example.com/nonexistent.jpg: 404 Not Found',
169
+ );
170
+
171
+ expect(mockFetch).toHaveBeenCalledWith('https://example.com/nonexistent.jpg');
172
+ });
173
+
174
+ it('should throw error when network request fails', async () => {
175
+ mockFetch.mockRejectedValueOnce(new Error('Network error'));
176
+
177
+ await expect(fetchImageFromUrl('https://example.com/image.jpg')).rejects.toThrow(
178
+ 'Network error',
179
+ );
180
+ });
181
+ });
182
+
183
+ describe('edge cases', () => {
184
+ it('should handle base64 data URI correctly', async () => {
185
+ const base64Data =
186
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
187
+ const dataUri = `data:image/png;base64,${base64Data}`;
188
+
189
+ const result = await fetchImageFromUrl(dataUri);
190
+
191
+ expect(result.mimeType).toBe('image/png');
192
+ expect(result.buffer).toBeInstanceOf(Buffer);
193
+ });
194
+
195
+ it('should throw error for invalid data URI format', async () => {
196
+ const invalidDataUri = 'data:image/png:invalid-format';
197
+
198
+ await expect(fetchImageFromUrl(invalidDataUri)).rejects.toThrow(
199
+ 'Invalid data URI format: data:image/png:invalid-format',
200
+ );
201
+ });
202
+
203
+ it('should throw error for malformed data URI without base64', async () => {
204
+ const malformedDataUri = 'data:image/png;charset=utf-8,not-base64-data';
205
+
206
+ await expect(fetchImageFromUrl(malformedDataUri)).rejects.toThrow(
207
+ 'Invalid data URI format: data:image/png;charset=utf-8,not-base64-data',
208
+ );
209
+ });
210
+
211
+ it('should handle different URL schemes', async () => {
212
+ const mockBuffer = Buffer.from('mock image data');
213
+ const mockArrayBuffer = mockBuffer.buffer.slice(
214
+ mockBuffer.byteOffset,
215
+ mockBuffer.byteOffset + mockBuffer.byteLength,
216
+ );
217
+
218
+ mockFetch.mockResolvedValueOnce({
219
+ ok: true,
220
+ status: 200,
221
+ headers: {
222
+ get: vi.fn().mockReturnValue('image/png'),
223
+ },
224
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
225
+ });
226
+
227
+ const result = await fetchImageFromUrl('http://example.com/image.png');
228
+
229
+ expect(result.mimeType).toBe('image/png');
230
+ expect(result.buffer).toBeInstanceOf(Buffer);
231
+ });
232
+ });
233
+
234
+ describe('return type validation', () => {
235
+ it('should return object with correct structure', async () => {
236
+ const base64Data =
237
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
238
+ const dataUri = `data:image/png;base64,${base64Data}`;
239
+
240
+ const result = await fetchImageFromUrl(dataUri);
241
+
242
+ expect(result).toHaveProperty('buffer');
243
+ expect(result).toHaveProperty('mimeType');
244
+ expect(typeof result.mimeType).toBe('string');
245
+ expect(result.buffer).toBeInstanceOf(Buffer);
246
+ });
247
+ });
248
+ });
249
+
250
+ describe('transformImageForGeneration', () => {
251
+ const mockOriginalBuffer = Buffer.from('original image data');
252
+ const mockThumbnailBuffer = Buffer.from('thumbnail image data');
253
+
254
+ beforeEach(() => {
255
+ // Reset and configure sha256 with stable implementation
256
+ vi.mocked(sha256)
257
+ .mockReset()
258
+ .mockImplementation(
259
+ (buffer: any) => `hash-${buffer.length}-${buffer.slice(0, 4).toString('hex')}`,
260
+ );
261
+ });
262
+
263
+ it('should transform base64 image successfully', async () => {
264
+ const base64Data =
265
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
266
+ const dataUri = `data:image/png;base64,${base64Data}`;
267
+
268
+ const mockSharp = {
269
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
270
+ resize: vi.fn().mockReturnThis(),
271
+ webp: vi.fn().mockReturnThis(),
272
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
273
+ };
274
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
275
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
276
+ shouldResize: true,
277
+ thumbnailWidth: 512,
278
+ thumbnailHeight: 384,
279
+ });
280
+
281
+ const result = await service.transformImageForGeneration(dataUri);
282
+
283
+ // Verify image properties
284
+ expect(result.image.width).toBe(800);
285
+ expect(result.image.height).toBe(600);
286
+ expect(result.image.extension).toBe('png');
287
+ expect(result.image.hash).toMatch(/^hash-\d+-/); // Matches our stable hash format
288
+
289
+ // Verify thumbnail properties
290
+ expect(result.thumbnailImage.width).toBe(512);
291
+ expect(result.thumbnailImage.height).toBe(384);
292
+ expect(result.thumbnailImage.hash).toMatch(/^hash-\d+-/);
293
+
294
+ // Verify resize was called with correct dimensions
295
+ expect(mockSharp.resize).toHaveBeenCalledWith(512, 384);
296
+ expect(mockSharp.resize).toHaveBeenCalledTimes(1);
297
+
298
+ // Verify sha256 was called twice (for original and thumbnail)
299
+ expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2);
300
+ });
301
+
302
+ it('should handle HTTP URL successfully', async () => {
303
+ const url = 'https://example.com/image.jpg';
304
+
305
+ // Mock fetch for HTTP URL
306
+ const mockArrayBuffer = mockOriginalBuffer.buffer.slice(
307
+ mockOriginalBuffer.byteOffset,
308
+ mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength,
309
+ );
310
+ mockFetch.mockResolvedValueOnce({
311
+ ok: true,
312
+ status: 200,
313
+ headers: {
314
+ get: vi.fn().mockReturnValue('image/jpeg'),
315
+ },
316
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
317
+ });
318
+
319
+ const mockSharp = {
320
+ metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 1024, height: 768 }),
321
+ resize: vi.fn().mockReturnThis(),
322
+ webp: vi.fn().mockReturnThis(),
323
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
324
+ };
325
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
326
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
327
+ shouldResize: true,
328
+ thumbnailWidth: 512,
329
+ thumbnailHeight: 384,
330
+ });
331
+
332
+ const result = await service.transformImageForGeneration(url);
333
+
334
+ expect(result.image.width).toBe(1024);
335
+ expect(result.image.height).toBe(768);
336
+ expect(result.image.extension).toBe('jpg'); // URL is image.jpg, so extension should be jpg
337
+ expect(result.thumbnailImage.width).toBe(512);
338
+ expect(result.thumbnailImage.height).toBe(384);
339
+ });
340
+
341
+ it('should handle images that do not need resizing', async () => {
342
+ const base64Data =
343
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
344
+ const dataUri = `data:image/png;base64,${base64Data}`;
345
+
346
+ const mockSharp = {
347
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 256, height: 256 }),
348
+ resize: vi.fn().mockReturnThis(),
349
+ webp: vi.fn().mockReturnThis(),
350
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
351
+ };
352
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
353
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
354
+ shouldResize: false,
355
+ thumbnailWidth: 256,
356
+ thumbnailHeight: 256,
357
+ });
358
+
359
+ const result = await service.transformImageForGeneration(dataUri);
360
+
361
+ // When no resizing is needed but format is not webp, thumbnail is still processed for format conversion
362
+ const expectedBuffer = Buffer.from(base64Data, 'base64');
363
+ expect(result.image.buffer).toEqual(expectedBuffer);
364
+ // Thumbnail buffer will be different because it's converted to WebP even without resizing
365
+ expect(result.thumbnailImage.buffer).toEqual(mockThumbnailBuffer);
366
+ // Resize is called with original dimensions for format conversion
367
+ expect(mockSharp.resize).toHaveBeenCalledWith(256, 256);
368
+ });
369
+
370
+ it('should throw error for invalid image format', async () => {
371
+ const dataUri = 'data:image/png;base64,invalid-data';
372
+
373
+ const mockSharp = {
374
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: null, height: null }),
375
+ };
376
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
377
+
378
+ await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
379
+ 'Invalid image format: png, url: data:image/png;base64,invalid-data',
380
+ );
381
+ });
382
+
383
+ it('should throw error when unable to determine extension from MIME type', async () => {
384
+ const dataUri = 'data:image/unknown;base64,some-data';
385
+ vi.mocked(mime.getExtension).mockReturnValue(null);
386
+
387
+ const mockSharp = {
388
+ metadata: vi.fn().mockResolvedValue({ format: 'unknown', width: 100, height: 100 }),
389
+ resize: vi.fn().mockReturnThis(),
390
+ webp: vi.fn().mockReturnThis(),
391
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
392
+ };
393
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
394
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
395
+ shouldResize: false,
396
+ thumbnailWidth: 100,
397
+ thumbnailHeight: 100,
398
+ });
399
+
400
+ await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
401
+ 'Unable to determine file extension for MIME type: image/unknown',
402
+ );
403
+ });
404
+
405
+ it('should throw error when unable to determine extension from URL', async () => {
406
+ const url = 'https://example.com/image';
407
+ vi.mocked(inferFileExtensionFromImageUrl).mockReturnValue('');
408
+
409
+ // Mock fetch for HTTP URL
410
+ const mockArrayBuffer = mockOriginalBuffer.buffer.slice(
411
+ mockOriginalBuffer.byteOffset,
412
+ mockOriginalBuffer.byteOffset + mockOriginalBuffer.byteLength,
413
+ );
414
+ mockFetch.mockResolvedValueOnce({
415
+ ok: true,
416
+ status: 200,
417
+ headers: {
418
+ get: vi.fn().mockReturnValue('image/jpeg'),
419
+ },
420
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
421
+ });
422
+
423
+ const mockSharp = {
424
+ metadata: vi.fn().mockResolvedValue({ format: 'jpeg', width: 100, height: 100 }),
425
+ resize: vi.fn().mockReturnThis(),
426
+ webp: vi.fn().mockReturnThis(),
427
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
428
+ };
429
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
430
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
431
+ shouldResize: false,
432
+ thumbnailWidth: 100,
433
+ thumbnailHeight: 100,
434
+ });
435
+
436
+ await expect(service.transformImageForGeneration(url)).rejects.toThrow(
437
+ 'Unable to determine file extension from URL: https://example.com/image',
438
+ );
439
+ });
440
+
441
+ it('should handle sharp processing error', async () => {
442
+ const dataUri = 'data:image/png;base64,invalid-data';
443
+
444
+ const mockSharp = {
445
+ metadata: vi.fn().mockRejectedValue(new Error('Invalid image data')),
446
+ };
447
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
448
+
449
+ await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
450
+ 'Invalid image data',
451
+ );
452
+ });
453
+
454
+ it('should handle sharp resize error', async () => {
455
+ const base64Data =
456
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
457
+ const dataUri = `data:image/png;base64,${base64Data}`;
458
+
459
+ const mockSharp = {
460
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
461
+ resize: vi.fn().mockReturnThis(),
462
+ webp: vi.fn().mockReturnThis(),
463
+ toBuffer: vi.fn().mockRejectedValue(new Error('Sharp processing failed')),
464
+ };
465
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
466
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
467
+ shouldResize: true,
468
+ thumbnailWidth: 512,
469
+ thumbnailHeight: 384,
470
+ });
471
+
472
+ await expect(service.transformImageForGeneration(dataUri)).rejects.toThrow(
473
+ 'Sharp processing failed',
474
+ );
475
+ });
476
+
477
+ it('should validate resize dimensions are called correctly', async () => {
478
+ const base64Data =
479
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
480
+ const dataUri = `data:image/png;base64,${base64Data}`;
481
+
482
+ const mockSharp = {
483
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1024, height: 768 }),
484
+ resize: vi.fn().mockReturnThis(),
485
+ webp: vi.fn().mockReturnThis(),
486
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
487
+ };
488
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
489
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
490
+ shouldResize: true,
491
+ thumbnailWidth: 400,
492
+ thumbnailHeight: 300,
493
+ });
494
+
495
+ await service.transformImageForGeneration(dataUri);
496
+
497
+ // Verify resize was called with exact calculated dimensions
498
+ expect(mockSharp.resize).toHaveBeenCalledWith(400, 300);
499
+ expect(mockSharp.resize).toHaveBeenCalledTimes(1);
500
+
501
+ // Verify calculateThumbnailDimensions was called with original dimensions
502
+ expect(calculateThumbnailDimensions).toHaveBeenCalledWith(1024, 768);
503
+ });
504
+
505
+ it('should validate file naming pattern includes correct dimensions', async () => {
506
+ const base64Data =
507
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
508
+ const dataUri = `data:image/png;base64,${base64Data}`;
509
+
510
+ const mockSharp = {
511
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 1920, height: 1080 }),
512
+ resize: vi.fn().mockReturnThis(),
513
+ webp: vi.fn().mockReturnThis(),
514
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
515
+ };
516
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
517
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
518
+ shouldResize: true,
519
+ thumbnailWidth: 512,
520
+ thumbnailHeight: 288,
521
+ });
522
+
523
+ const result = await service.transformImageForGeneration(dataUri);
524
+
525
+ // Verify original image dimensions are preserved
526
+ expect(result.image.width).toBe(1920);
527
+ expect(result.image.height).toBe(1080);
528
+
529
+ // Verify thumbnail dimensions match calculation
530
+ expect(result.thumbnailImage.width).toBe(512);
531
+ expect(result.thumbnailImage.height).toBe(288);
532
+
533
+ // Verify proper extensions - image keeps original, thumbnail becomes webp
534
+ expect(result.image.extension).toBe('png');
535
+ expect(result.thumbnailImage.extension).toBe('webp');
536
+ });
537
+
538
+ it('should verify sha256 is called exactly twice for transformations', async () => {
539
+ const base64Data =
540
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
541
+ const dataUri = `data:image/png;base64,${base64Data}`;
542
+
543
+ const mockSharp = {
544
+ metadata: vi.fn().mockResolvedValue({ format: 'png', width: 800, height: 600 }),
545
+ resize: vi.fn().mockReturnThis(),
546
+ webp: vi.fn().mockReturnThis(),
547
+ toBuffer: vi.fn().mockResolvedValue(mockThumbnailBuffer),
548
+ };
549
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
550
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
551
+ shouldResize: true,
552
+ thumbnailWidth: 512,
553
+ thumbnailHeight: 384,
554
+ });
555
+
556
+ await service.transformImageForGeneration(dataUri);
557
+
558
+ // Should call sha256 exactly twice: once for original, once for thumbnail
559
+ expect(vi.mocked(sha256)).toHaveBeenCalledTimes(2);
560
+
561
+ // Verify it's called with Buffer instances
562
+ const calls = vi.mocked(sha256).mock.calls;
563
+ expect(calls[0][0]).toBeInstanceOf(Buffer); // Original image buffer
564
+ expect(calls[1][0]).toBeInstanceOf(Buffer); // Thumbnail buffer
565
+ });
566
+ });
567
+
568
+ describe('uploadImageForGeneration', () => {
569
+ const mockImage = {
570
+ buffer: Buffer.from('image data'),
571
+ extension: 'png',
572
+ hash: 'image-hash',
573
+ height: 800,
574
+ mime: 'image/png',
575
+ size: 1000,
576
+ width: 600,
577
+ };
578
+
579
+ const mockThumbnail = {
580
+ buffer: Buffer.from('thumbnail data'),
581
+ extension: 'png',
582
+ hash: 'thumbnail-hash',
583
+ height: 400,
584
+ mime: 'image/png',
585
+ size: 500,
586
+ width: 300,
587
+ };
588
+
589
+ it('should upload both images when buffers are different', async () => {
590
+ mockFileService.uploadMedia
591
+ .mockResolvedValueOnce({
592
+ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
593
+ })
594
+ .mockResolvedValueOnce({
595
+ key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
596
+ });
597
+
598
+ const result = await service.uploadImageForGeneration(mockImage, mockThumbnail);
599
+
600
+ expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
601
+
602
+ // Verify correct file naming pattern with dimensions
603
+ expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith(
604
+ 1,
605
+ expect.stringMatching(/^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/),
606
+ mockImage.buffer,
607
+ );
608
+ expect(mockFileService.uploadMedia).toHaveBeenNthCalledWith(
609
+ 2,
610
+ expect.stringMatching(/^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/),
611
+ mockThumbnail.buffer,
612
+ );
613
+
614
+ expect(result).toEqual({
615
+ imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
616
+ thumbnailImageUrl: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
617
+ });
618
+ });
619
+
620
+ it('should upload single image when buffers are identical', async () => {
621
+ const identicalBuffer = Buffer.from('same data');
622
+ const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer };
623
+ const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer };
624
+
625
+ mockFileService.uploadMedia.mockResolvedValueOnce({
626
+ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
627
+ });
628
+
629
+ const result = await service.uploadImageForGeneration(
630
+ imageWithSameBuffer,
631
+ thumbnailWithSameBuffer,
632
+ );
633
+
634
+ expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1);
635
+ expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
636
+ 'generations/images/test-uuid_600x800_20240101123000_raw.png',
637
+ identicalBuffer,
638
+ );
639
+ expect(result).toEqual({
640
+ imageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
641
+ thumbnailImageUrl: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
642
+ });
643
+ });
644
+
645
+ it('should handle partial upload failure in concurrent uploads', async () => {
646
+ mockFileService.uploadMedia
647
+ .mockResolvedValueOnce({
648
+ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
649
+ })
650
+ .mockRejectedValueOnce(new Error('Thumbnail upload failed'));
651
+
652
+ await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow(
653
+ 'Thumbnail upload failed',
654
+ );
655
+
656
+ // Verify both uploads were attempted
657
+ expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
658
+ });
659
+
660
+ it('should handle complete upload failure', async () => {
661
+ mockFileService.uploadMedia
662
+ .mockRejectedValueOnce(new Error('Image upload failed'))
663
+ .mockRejectedValueOnce(new Error('Thumbnail upload failed'));
664
+
665
+ await expect(service.uploadImageForGeneration(mockImage, mockThumbnail)).rejects.toThrow(
666
+ 'Image upload failed',
667
+ );
668
+
669
+ // Should fail fast on first rejection
670
+ expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(2);
671
+ });
672
+
673
+ it('should handle single image upload failure', async () => {
674
+ const identicalBuffer = Buffer.from('same data');
675
+ const imageWithSameBuffer = { ...mockImage, buffer: identicalBuffer };
676
+ const thumbnailWithSameBuffer = { ...mockThumbnail, buffer: identicalBuffer };
677
+
678
+ mockFileService.uploadMedia.mockRejectedValueOnce(new Error('Upload service unavailable'));
679
+
680
+ await expect(
681
+ service.uploadImageForGeneration(imageWithSameBuffer, thumbnailWithSameBuffer),
682
+ ).rejects.toThrow('Upload service unavailable');
683
+
684
+ expect(mockFileService.uploadMedia).toHaveBeenCalledTimes(1);
685
+ });
686
+
687
+ it('should validate file naming format with correct patterns', async () => {
688
+ mockFileService.uploadMedia
689
+ .mockResolvedValueOnce({
690
+ key: 'generations/images/test-uuid_600x800_20240101123000_raw.png',
691
+ })
692
+ .mockResolvedValueOnce({
693
+ key: 'generations/images/test-uuid_300x400_20240101123000_thumb.png',
694
+ });
695
+
696
+ await service.uploadImageForGeneration(mockImage, mockThumbnail);
697
+
698
+ // Verify file name patterns match exact format: {uuid}_{width}x{height}_{timestamp}_{type}.{ext}
699
+ const imageCall = mockFileService.uploadMedia.mock.calls[0];
700
+ const thumbnailCall = mockFileService.uploadMedia.mock.calls[1];
701
+
702
+ expect(imageCall[0]).toMatch(
703
+ /^generations\/images\/test-uuid_600x800_20240101123000_raw\.png$/,
704
+ );
705
+ expect(thumbnailCall[0]).toMatch(
706
+ /^generations\/images\/test-uuid_300x400_20240101123000_thumb\.png$/,
707
+ );
708
+
709
+ // Verify dimensions are correctly embedded in filename
710
+ expect(imageCall[0]).toContain('600x800'); // Original dimensions
711
+ expect(thumbnailCall[0]).toContain('300x400'); // Thumbnail dimensions
712
+
713
+ // Verify file type suffixes
714
+ expect(imageCall[0]).toContain('_raw.');
715
+ expect(thumbnailCall[0]).toContain('_thumb.');
716
+ });
717
+ });
718
+
719
+ describe('createCoverFromUrl', () => {
720
+ const mockCoverBuffer = Buffer.from('cover image data');
721
+
722
+ // Note: Using global mock configuration from parent describe
723
+
724
+ it('should create cover from base64 data URI', async () => {
725
+ const base64Data =
726
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChAGAWqGfKwAAAABJRU5ErkJggg==';
727
+ const dataUri = `data:image/jpeg;base64,${base64Data}`;
728
+
729
+ const mockSharp = {
730
+ metadata: vi.fn().mockResolvedValue({ width: 512, height: 384 }),
731
+ resize: vi.fn().mockReturnThis(),
732
+ webp: vi.fn().mockReturnThis(),
733
+ toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
734
+ };
735
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
736
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
737
+ shouldResize: true,
738
+ thumbnailWidth: 256,
739
+ thumbnailHeight: 192,
740
+ });
741
+
742
+ mockFileService.uploadMedia.mockResolvedValueOnce({
743
+ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
744
+ });
745
+
746
+ const result = await service.createCoverFromUrl(dataUri);
747
+
748
+ expect(mockSharp.resize).toHaveBeenCalledWith(256, 192);
749
+ expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
750
+ 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
751
+ mockCoverBuffer,
752
+ );
753
+ expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
754
+ });
755
+
756
+ it('should create cover from HTTP URL', async () => {
757
+ const url = 'https://example.com/image.jpg';
758
+
759
+ // Mock fetch for HTTP URL
760
+ const mockBuffer = Buffer.from('original image data');
761
+ const mockArrayBuffer = mockBuffer.buffer.slice(
762
+ mockBuffer.byteOffset,
763
+ mockBuffer.byteOffset + mockBuffer.byteLength,
764
+ );
765
+ mockFetch.mockResolvedValueOnce({
766
+ ok: true,
767
+ status: 200,
768
+ headers: {
769
+ get: vi.fn().mockReturnValue('image/jpeg'),
770
+ },
771
+ arrayBuffer: vi.fn().mockResolvedValue(mockArrayBuffer),
772
+ });
773
+
774
+ const mockSharp = {
775
+ metadata: vi.fn().mockResolvedValue({ width: 800, height: 600 }),
776
+ resize: vi.fn().mockReturnThis(),
777
+ webp: vi.fn().mockReturnThis(),
778
+ toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
779
+ };
780
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
781
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
782
+ shouldResize: true,
783
+ thumbnailWidth: 256,
784
+ thumbnailHeight: 192,
785
+ });
786
+
787
+ mockFileService.uploadMedia.mockResolvedValueOnce({
788
+ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
789
+ });
790
+
791
+ const result = await service.createCoverFromUrl(url);
792
+
793
+ expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
794
+ });
795
+
796
+ it('should throw error for invalid image format', async () => {
797
+ const dataUri = 'data:image/png;base64,invalid-data';
798
+
799
+ const mockSharp = {
800
+ metadata: vi.fn().mockResolvedValue({ width: null, height: null }),
801
+ };
802
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
803
+
804
+ await expect(service.createCoverFromUrl(dataUri)).rejects.toThrow(
805
+ 'Invalid image format for cover creation',
806
+ );
807
+ });
808
+
809
+ it('should validate cover file naming format includes dimensions', async () => {
810
+ const dataUri = 'data:image/jpeg;base64,some-data';
811
+
812
+ const mockSharp = {
813
+ metadata: vi.fn().mockResolvedValue({ width: 1024, height: 768 }),
814
+ resize: vi.fn().mockReturnThis(),
815
+ webp: vi.fn().mockReturnThis(),
816
+ toBuffer: vi.fn().mockResolvedValue(mockCoverBuffer),
817
+ };
818
+ vi.mocked(sharp).mockReturnValue(mockSharp as any);
819
+ vi.mocked(calculateThumbnailDimensions).mockReturnValue({
820
+ shouldResize: true,
821
+ thumbnailWidth: 256,
822
+ thumbnailHeight: 192,
823
+ });
824
+
825
+ mockFileService.uploadMedia.mockResolvedValueOnce({
826
+ key: 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
827
+ });
828
+
829
+ const result = await service.createCoverFromUrl(dataUri);
830
+
831
+ // Verify cover filename contains calculated dimensions
832
+ expect(mockFileService.uploadMedia).toHaveBeenCalledWith(
833
+ 'generations/covers/test-uuid_256x192_20240101123000_cover.webp',
834
+ mockCoverBuffer,
835
+ );
836
+
837
+ // Verify filename pattern: {uuid}_{width}x{height}_{timestamp}_cover.{ext}
838
+ const filename = mockFileService.uploadMedia.mock.calls[0][0];
839
+ expect(filename).toMatch(
840
+ /^generations\/covers\/test-uuid_256x192_20240101123000_cover\.webp$/,
841
+ );
842
+ expect(filename).toContain('256x192'); // Cover dimensions
843
+ expect(filename).toContain('_cover.'); // Cover suffix
844
+
845
+ expect(result).toBe('generations/covers/test-uuid_256x192_20240101123000_cover.webp');
846
+ });
847
+ });
848
+ });