@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.
- package/.cursor/rules/code-review.mdc +2 -0
- package/.cursor/rules/typescript.mdc +3 -1
- package/CHANGELOG.md +25 -0
- package/changelog/v1.json +9 -0
- package/package.json +1 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/BatchItem.tsx +6 -1
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/ErrorState.tsx +3 -2
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/LoadingState.tsx +27 -24
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/SuccessState.tsx +14 -3
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/index.tsx +4 -7
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/types.ts +3 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.test.ts +600 -0
- package/src/app/[variants]/(main)/image/features/GenerationFeed/GenerationItem/utils.ts +126 -7
- package/src/const/imageGeneration.ts +18 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.test.ts +3 -0
- package/src/libs/model-runtime/utils/openaiCompatibleFactory/index.ts +7 -5
- package/src/libs/model-runtime/utils/streams/openai/openai.ts +8 -4
- package/src/libs/model-runtime/utils/usageConverter.test.ts +45 -1
- package/src/libs/model-runtime/utils/usageConverter.ts +6 -2
- package/src/server/services/generation/index.test.ts +848 -0
- package/src/server/services/generation/index.ts +90 -69
- package/src/utils/number.test.ts +101 -1
- 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
|
+
});
|