@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.
- package/dist/modules/sync_akeneo/lib/catalog-importer.js +89 -40
- package/dist/modules/sync_akeneo/lib/catalog-importer.js.map +2 -2
- package/dist/modules/sync_akeneo/lib/client.js +143 -70
- package/dist/modules/sync_akeneo/lib/client.js.map +2 -2
- package/package.json +5 -5
- package/src/modules/sync_akeneo/__tests__/catalog-importer.media-limits.test.ts +426 -0
- package/src/modules/sync_akeneo/__tests__/client.response-limits.test.ts +339 -0
- package/src/modules/sync_akeneo/i18n/de.json +1 -0
- package/src/modules/sync_akeneo/i18n/en.json +1 -0
- package/src/modules/sync_akeneo/i18n/es.json +1 -0
- package/src/modules/sync_akeneo/i18n/ko.json +1 -0
- package/src/modules/sync_akeneo/i18n/pl.json +1 -0
- package/src/modules/sync_akeneo/lib/catalog-importer.ts +97 -44
- package/src/modules/sync_akeneo/lib/client.ts +174 -98
|
@@ -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
|
+
})
|