@open-mercato/sync-akeneo 0.9.1-develop.7553.1.8dbab05b5a → 0.9.1-develop.7567.1.b5e80ff620

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.
@@ -0,0 +1,426 @@
1
+ import { CatalogProduct, CatalogProductVariant } from '@open-mercato/core/modules/catalog/data/entities'
2
+ import { ScopedAttachmentUploadError } from '@open-mercato/core/modules/attachments/lib/scoped-upload-service'
3
+ import { CrudHttpError } from '@open-mercato/shared/lib/crud/errors'
4
+ import { createAkeneoImporter } from '../lib/catalog-importer'
5
+ import { buildDefaultAkeneoMapping, type AkeneoDataMapping, type AkeneoProduct } from '../lib/shared'
6
+
7
+ const mockContainerResolve = jest.fn()
8
+ const mockFindOneWithDecryption = jest.fn()
9
+ const mockFindWithDecryption = jest.fn()
10
+ const mockStorePartitionFile = jest.fn()
11
+ const mockEmitCrudSideEffects = jest.fn()
12
+
13
+ jest.mock('@open-mercato/core/modules/attachments/lib/crud', () => ({
14
+ attachmentCrudEvents: {},
15
+ attachmentCrudIndexer: {},
16
+ }))
17
+
18
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
19
+ createRequestContainer: jest.fn(async () => ({ resolve: mockContainerResolve })),
20
+ }))
21
+
22
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => ({
23
+ findOneWithDecryption: (...args: unknown[]) => mockFindOneWithDecryption(...args),
24
+ findWithDecryption: (...args: unknown[]) => mockFindWithDecryption(...args),
25
+ }))
26
+
27
+ jest.mock('@open-mercato/shared/lib/commands/helpers', () => ({
28
+ ...jest.requireActual('@open-mercato/shared/lib/commands/helpers'),
29
+ emitCrudSideEffects: (...args: unknown[]) => mockEmitCrudSideEffects(...args),
30
+ }))
31
+
32
+ jest.mock('@open-mercato/core/modules/attachments/lib/storage', () => ({
33
+ storePartitionFile: (...args: unknown[]) => mockStorePartitionFile(...args),
34
+ deletePartitionFile: jest.fn(async () => undefined),
35
+ }))
36
+
37
+ jest.mock('@open-mercato/core/modules/attachments/lib/partitions', () => ({
38
+ ensureDefaultPartitions: jest.fn(async () => undefined),
39
+ resolveDefaultPartitionCode: jest.fn(() => 'productsMedia'),
40
+ }))
41
+
42
+ const scope = { organizationId: 'org-1', tenantId: 'tenant-1' }
43
+ const MEDIA_ATTRIBUTE = 'gallery'
44
+
45
+ type UploadInput = {
46
+ tenantId: string
47
+ organizationId: string
48
+ entityId: string
49
+ recordId: string
50
+ fileName: string
51
+ declaredMimeType?: string | null
52
+ buffer: Buffer
53
+ }
54
+
55
+ type UploadedRow = {
56
+ id: string
57
+ entityId: string
58
+ recordId: string
59
+ organizationId: string
60
+ tenantId: string
61
+ fileName: string
62
+ fileSize: number
63
+ url: string
64
+ storageMetadata: Record<string, unknown> | null
65
+ }
66
+
67
+ type MediaBinary = {
68
+ buffer: Buffer
69
+ contentType: string | null
70
+ contentLength: number | null
71
+ fileName: string | null
72
+ code: string | null
73
+ }
74
+
75
+ function createMediaWorld(download: (code: string) => Promise<MediaBinary>) {
76
+ const products: Array<{ id: string }> = []
77
+ const variants: Array<{ id: string; product: string; sku: string; isDefault: boolean }> = []
78
+ const mappings: Array<{ entityType: string; localId: string; externalId: string }> = []
79
+ const uploaded: UploadedRow[] = []
80
+ const flushedSnapshots: Array<Array<Pick<UploadedRow, 'id' | 'url' | 'storageMetadata'>>> = []
81
+ let sequence = 0
82
+ const nextId = (prefix: string) => `${prefix}-${++sequence}`
83
+
84
+ const commandBus = {
85
+ execute: jest.fn(async (commandId: string, { input }: { input: Record<string, unknown> }) => {
86
+ switch (commandId) {
87
+ case 'catalog.products.create': {
88
+ const id = nextId('product')
89
+ products.push({ id })
90
+ return { result: { productId: id } }
91
+ }
92
+ case 'catalog.products.update':
93
+ return { result: { productId: input.id } }
94
+ case 'catalog.variants.create': {
95
+ const id = nextId('variant')
96
+ variants.push({ id, product: String(input.productId), sku: String(input.sku), isDefault: input.isDefault === true })
97
+ return { result: { variantId: id } }
98
+ }
99
+ case 'catalog.variants.update':
100
+ return { result: { variantId: input.id } }
101
+ default:
102
+ throw new Error(`[internal] unexpected command: ${commandId}`)
103
+ }
104
+ }),
105
+ }
106
+
107
+ const externalIdMappingService = {
108
+ lookupLocalId: jest.fn(async (_integrationId: string, entityType: string, externalId: string) => (
109
+ mappings.find((row) => row.entityType === entityType && row.externalId === externalId)?.localId ?? null
110
+ )),
111
+ lookupExternalId: jest.fn(async () => null),
112
+ storeExternalIdMapping: jest.fn(async (_integrationId: string, entityType: string, localId: string, externalId: string) => {
113
+ mappings.push({ entityType, localId, externalId })
114
+ }),
115
+ }
116
+
117
+ const removed: Array<{ id: string }> = []
118
+ const em = {
119
+ create: jest.fn((_entity: unknown, data: Record<string, unknown>) => ({ ...data, id: nextId('raw-attachment') })),
120
+ persist: jest.fn(),
121
+ remove: jest.fn((entity: { id: string }) => {
122
+ removed.push(entity)
123
+ return { flush: async () => undefined }
124
+ }),
125
+ flush: jest.fn(async () => {
126
+ flushedSnapshots.push(uploaded.map((row) => ({ id: row.id, url: row.url, storageMetadata: row.storageMetadata })))
127
+ }),
128
+ }
129
+
130
+ const uploadService = {
131
+ upload: jest.fn(async (input: UploadInput) => {
132
+ const row: UploadedRow = {
133
+ id: nextId('attachment'),
134
+ entityId: input.entityId,
135
+ recordId: input.recordId,
136
+ organizationId: input.organizationId,
137
+ tenantId: input.tenantId,
138
+ fileName: input.fileName,
139
+ fileSize: input.buffer.length,
140
+ url: `/api/attachments/file/pending`,
141
+ storageMetadata: { assignments: [{ type: input.entityId, id: input.recordId }] },
142
+ }
143
+ uploaded.push(row)
144
+ return row
145
+ }),
146
+ }
147
+
148
+ const integrationLogService = { write: jest.fn(async () => ({})) }
149
+ const attachmentService = { releaseScoped: jest.fn(async () => undefined) }
150
+
151
+ mockContainerResolve.mockImplementation((key: string) => {
152
+ if (key === 'integrationLogService') return integrationLogService
153
+ if (key === 'attachmentService') return attachmentService
154
+ if (key === 'em') return em
155
+ if (key === 'commandBus') return commandBus
156
+ if (key === 'dataEngine') return { flushOrmEntityChanges: jest.fn(async () => undefined) }
157
+ if (key === 'externalIdMappingService') return externalIdMappingService
158
+ if (key === 'attachmentScopedUploadService') return uploadService
159
+ if (key === 'cache') throw new Error('[internal] no cache in test')
160
+ throw new Error(`[internal] unexpected dependency: ${key}`)
161
+ })
162
+
163
+ mockFindOneWithDecryption.mockImplementation(async (_em: unknown, entity: unknown, where: Record<string, unknown>) => {
164
+ if (entity === CatalogProduct) return products.find((product) => product.id === where.id) ?? null
165
+ if (entity === CatalogProductVariant) {
166
+ return variants.find((variant) => (
167
+ (where.id === undefined || variant.id === where.id)
168
+ && (where.product === undefined || variant.product === where.product)
169
+ && (where.sku === undefined || variant.sku === where.sku)
170
+ && (where.isDefault === undefined || variant.isDefault === where.isDefault)
171
+ )) ?? null
172
+ }
173
+ return null
174
+ })
175
+ mockFindWithDecryption.mockImplementation(async () => [])
176
+ mockStorePartitionFile.mockImplementation(async (payload: { fileName: string }) => ({
177
+ storagePath: `org_org-1/tenant_tenant-1/${payload.fileName}`,
178
+ absolutePath: `/tmp/${payload.fileName}`,
179
+ fileName: payload.fileName,
180
+ }))
181
+
182
+ const client = {
183
+ getFamily: jest.fn(async () => null),
184
+ getFamilyVariant: jest.fn(async () => null),
185
+ getAttribute: jest.fn(async (code: string) => (
186
+ code === MEDIA_ATTRIBUTE
187
+ ? { code: MEDIA_ATTRIBUTE, type: 'pim_catalog_image', localizable: false, scopable: false, labels: {} }
188
+ : null
189
+ )),
190
+ getProductModel: jest.fn(async () => null),
191
+ downloadMediaFile: jest.fn(download),
192
+ }
193
+
194
+ return {
195
+ client,
196
+ commandBus,
197
+ uploadService,
198
+ uploaded,
199
+ mappings,
200
+ em,
201
+ flushedSnapshots,
202
+ integrationLogService,
203
+ attachmentService,
204
+ removed,
205
+ }
206
+ }
207
+
208
+ function buildMediaMapping(): AkeneoDataMapping {
209
+ const base = buildDefaultAkeneoMapping('products')
210
+ const products = base.settings?.products
211
+ if (!products) throw new Error('[internal] default Akeneo product mapping is missing')
212
+ return {
213
+ ...base,
214
+ settings: {
215
+ ...base.settings,
216
+ products: {
217
+ ...products,
218
+ priceMappings: [],
219
+ mediaMappings: [{ attributeCode: MEDIA_ATTRIBUTE, target: 'product', kind: 'image' }],
220
+ customFieldMappings: [],
221
+ syncAssociations: false,
222
+ createMissingChannels: false,
223
+ },
224
+ },
225
+ }
226
+ }
227
+
228
+ function buildProductWithMedia(codes: string[]): AkeneoProduct {
229
+ return {
230
+ uuid: 'uuid-mug',
231
+ identifier: 'MUG',
232
+ parent: null,
233
+ enabled: true,
234
+ values: {
235
+ sku: [{ locale: null, scope: null, data: 'MUG' }],
236
+ [MEDIA_ATTRIBUTE]: [{ locale: null, scope: null, data: codes }],
237
+ },
238
+ }
239
+ }
240
+
241
+ function heroUpdateOf(world: ReturnType<typeof createMediaWorld>) {
242
+ return world.commandBus.execute.mock.calls
243
+ .map(([commandId, payload]) => ({ commandId, input: (payload as { input: Record<string, unknown> }).input }))
244
+ .find((call) => call.commandId === 'catalog.products.update' && 'defaultMediaId' in call.input)
245
+ }
246
+
247
+ function pngBinary(code: string, bytes: number, declaredLength: number | null = bytes): MediaBinary {
248
+ return { buffer: Buffer.alloc(bytes), contentType: 'image/png', contentLength: declaredLength, fileName: `${code}.png`, code }
249
+ }
250
+
251
+ describe('akeneo importer media storage (issue #7233)', () => {
252
+ beforeEach(() => {
253
+ mockContainerResolve.mockReset()
254
+ mockFindOneWithDecryption.mockReset()
255
+ mockFindWithDecryption.mockReset()
256
+ mockStorePartitionFile.mockReset()
257
+ mockEmitCrudSideEffects.mockReset()
258
+ mockEmitCrudSideEffects.mockImplementation(async () => undefined)
259
+ })
260
+
261
+ it('stores media through the scoped attachment upload service with the bytes actually received', async () => {
262
+ const world = createMediaWorld(async (code) => pngBinary(code, 10, 1))
263
+ const importer = await createAkeneoImporter(world.client as never, scope)
264
+
265
+ await importer.upsertProduct(buildProductWithMedia(['asset-1']), buildMediaMapping())
266
+
267
+ expect(mockStorePartitionFile).not.toHaveBeenCalled()
268
+ expect(world.em.create).not.toHaveBeenCalled()
269
+ expect(world.uploadService.upload).toHaveBeenCalledTimes(1)
270
+ const input = world.uploadService.upload.mock.calls[0]?.[0] as UploadInput
271
+ expect(input).toMatchObject({
272
+ tenantId: 'tenant-1',
273
+ organizationId: 'org-1',
274
+ entityId: 'catalog:catalog_product',
275
+ fileName: 'asset-1.png',
276
+ declaredMimeType: 'image/png',
277
+ })
278
+ expect(input.buffer.byteLength).toBe(10)
279
+ expect(world.uploaded[0]?.fileSize).toBe(10)
280
+ })
281
+
282
+ it('keeps the image URL, Akeneo metadata and attachment mapping on the uploaded row', async () => {
283
+ const world = createMediaWorld(async (code) => pngBinary(code, 4))
284
+ const importer = await createAkeneoImporter(world.client as never, scope)
285
+
286
+ await importer.upsertProduct(buildProductWithMedia(['asset-1']), buildMediaMapping())
287
+
288
+ const row = world.uploaded[0]
289
+ expect(row?.url).toBe(`/api/attachments/image/${row?.id}/asset-1.png`)
290
+ expect(row?.storageMetadata).toMatchObject({
291
+ assignments: [{ type: 'catalog:catalog_product', id: row?.recordId }],
292
+ source: 'akeneo',
293
+ kind: 'image',
294
+ remoteCode: 'asset-1',
295
+ })
296
+ expect(world.flushedSnapshots.some((snapshot) => snapshot.some((entry) => entry.id === row?.id && entry.url === row?.url))).toBe(true)
297
+ expect(world.mappings.filter((mapping) => mapping.entityType === 'attachment')).toEqual([
298
+ expect.objectContaining({ localId: row?.id }),
299
+ ])
300
+ const attachmentActions = mockEmitCrudSideEffects.mock.calls
301
+ .map(([args]) => args as { action: string; entity: { id?: string } })
302
+ .filter((args) => args.entity?.id === row?.id)
303
+ .map((args) => args.action)
304
+ expect(attachmentActions).toEqual(['updated'])
305
+ })
306
+
307
+ it('fails the product without storing an attachment mapping when attachment storage rejects the media', async () => {
308
+ const world = createMediaWorld(async (code) => pngBinary(code, 4))
309
+ world.uploadService.upload.mockRejectedValueOnce(new ScopedAttachmentUploadError('quota_exceeded', 413))
310
+ const importer = await createAkeneoImporter(world.client as never, scope)
311
+
312
+ await expect(importer.upsertProduct(buildProductWithMedia(['asset-1']), buildMediaMapping()))
313
+ .rejects.toThrow('Akeneo media asset-1 was rejected by attachment storage (quota_exceeded)')
314
+
315
+ expect(world.mappings.filter((mapping) => mapping.entityType === 'attachment')).toEqual([])
316
+ expect(mockStorePartitionFile).not.toHaveBeenCalled()
317
+ })
318
+
319
+ it('skips a media download that exceeds the size limit, logs a warning and keeps importing the product', async () => {
320
+ const tooLarge = Object.assign(new Error('Akeneo response exceeds the 1 byte limit'), { name: 'AkeneoResponseTooLargeError' })
321
+ const world = createMediaWorld(async (code) => {
322
+ if (code === 'asset-1') throw tooLarge
323
+ return pngBinary(code, 4)
324
+ })
325
+ const importer = await createAkeneoImporter(world.client as never, scope)
326
+
327
+ const items = await importer.upsertProduct(buildProductWithMedia(['asset-1', 'asset-2']), buildMediaMapping())
328
+
329
+ expect(items.length).toBeGreaterThan(0)
330
+ expect(world.uploadService.upload).toHaveBeenCalledTimes(1)
331
+ expect(world.uploaded.map((row) => row.fileName)).toEqual(['asset-2.png'])
332
+ expect(world.mappings.filter((mapping) => mapping.entityType === 'attachment')).toEqual([
333
+ expect.objectContaining({ localId: world.uploaded[0]?.id }),
334
+ ])
335
+ expect(heroUpdateOf(world)?.input.defaultMediaId).toBe(world.uploaded[0]?.id)
336
+ expect(world.integrationLogService.write).toHaveBeenCalledTimes(1)
337
+ expect(world.integrationLogService.write).toHaveBeenCalledWith(expect.objectContaining({
338
+ integrationId: 'sync_akeneo',
339
+ level: 'warn',
340
+ code: 'sync_akeneo.media_rejected',
341
+ payload: expect.objectContaining({ remoteCode: 'asset-1', reason: 'max_upload_size' }),
342
+ }), scope)
343
+ expect(mockStorePartitionFile).not.toHaveBeenCalled()
344
+ })
345
+
346
+ it.each(['active_content', 'max_upload_size', 'dangerous_executable'] as const)(
347
+ 'skips media the attachment policy rejects (%s) without failing the product',
348
+ async (code) => {
349
+ const world = createMediaWorld(async (mediaCode) => pngBinary(mediaCode, 4))
350
+ world.uploadService.upload.mockRejectedValueOnce(new ScopedAttachmentUploadError(code, 400))
351
+ const importer = await createAkeneoImporter(world.client as never, scope)
352
+
353
+ await importer.upsertProduct(buildProductWithMedia(['asset-1']), buildMediaMapping())
354
+
355
+ expect(world.mappings.filter((mapping) => mapping.entityType === 'attachment')).toEqual([])
356
+ expect(heroUpdateOf(world)?.input.defaultMediaId).toBeNull()
357
+ expect(world.integrationLogService.write).toHaveBeenCalledWith(expect.objectContaining({
358
+ level: 'warn',
359
+ payload: expect.objectContaining({ remoteCode: 'asset-1', reason: code }),
360
+ }), scope)
361
+ },
362
+ )
363
+
364
+ it('releases no-longer-desired media through the attachment service and keeps media other records still use', async () => {
365
+ const world = createMediaWorld(async (code) => pngBinary(code, 4))
366
+ const staleRows = ['stale-1', 'stale-shared'].map((id) => ({
367
+ id,
368
+ entityId: 'catalog:catalog_product',
369
+ recordId: 'product-1',
370
+ partitionCode: 'productsMedia',
371
+ storagePath: `org/tenant/${id}.png`,
372
+ storageDriver: 's3',
373
+ storageMetadata: { externalId: id, kind: 'image' },
374
+ }))
375
+ mockFindWithDecryption.mockImplementation(async () => staleRows)
376
+ world.attachmentService.releaseScoped
377
+ .mockImplementationOnce(async () => undefined)
378
+ .mockImplementationOnce(async () => { throw new CrudHttpError(409, { error: 'Attachment is still referenced by another record' }) })
379
+ const importer = await createAkeneoImporter(world.client as never, scope)
380
+ const mapping = buildMediaMapping()
381
+ const products = mapping.settings?.products
382
+ if (!products) throw new Error('[internal] media mapping is missing')
383
+ products.reconciliation = { ...products.reconciliation, deleteMissingMedia: true }
384
+
385
+ await importer.upsertProduct(buildProductWithMedia(['asset-1']), mapping)
386
+
387
+ expect(world.attachmentService.releaseScoped).toHaveBeenCalledTimes(2)
388
+ expect(world.attachmentService.releaseScoped).toHaveBeenNthCalledWith(1, {
389
+ attachmentId: 'stale-1',
390
+ tenantId: 'tenant-1',
391
+ organizationId: 'org-1',
392
+ expectedOwner: { entityId: 'catalog:catalog_product', recordId: 'product-1' },
393
+ expectedAssignment: { type: 'catalog:catalog_product', id: 'product-1' },
394
+ expectedPartitionCode: 'productsMedia',
395
+ })
396
+ const deletedIds = mockEmitCrudSideEffects.mock.calls
397
+ .map(([args]) => args as { action: string; entity: { id?: string } })
398
+ .filter((args) => args.action === 'deleted')
399
+ .map((args) => args.entity.id)
400
+ expect(deletedIds).toEqual(['stale-1'])
401
+ expect(world.removed).toEqual([])
402
+ expect(mockStorePartitionFile).not.toHaveBeenCalled()
403
+ })
404
+
405
+ it('downloads the media files of a product one at a time and keeps the first image as hero', async () => {
406
+ let inFlight = 0
407
+ let maxInFlight = 0
408
+ const world = createMediaWorld(async (code) => {
409
+ inFlight += 1
410
+ maxInFlight = Math.max(maxInFlight, inFlight)
411
+ await new Promise((resolve) => setTimeout(resolve, 5))
412
+ inFlight -= 1
413
+ return pngBinary(code, 4)
414
+ })
415
+ const importer = await createAkeneoImporter(world.client as never, scope)
416
+ const codes = Array.from({ length: 8 }, (_, index) => `asset-${index + 1}`)
417
+
418
+ await importer.upsertProduct(buildProductWithMedia(codes), buildMediaMapping())
419
+
420
+ expect(world.client.downloadMediaFile).toHaveBeenCalledTimes(8)
421
+ expect(world.client.downloadMediaFile.mock.calls.map(([code]) => code)).toEqual(codes)
422
+ expect(maxInFlight).toBe(1)
423
+ expect(world.uploaded[0]?.fileName).toBe('asset-1.png')
424
+ expect(heroUpdateOf(world)?.input.defaultMediaId).toBe(world.uploaded[0]?.id)
425
+ })
426
+ })