@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,339 @@
|
|
|
1
|
+
import { createAkeneoClient, type AkeneoClientDeps } from '../lib/client'
|
|
2
|
+
|
|
3
|
+
const validCredentials = {
|
|
4
|
+
apiUrl: 'https://tenant.cloud.akeneo.com',
|
|
5
|
+
clientId: 'client-id',
|
|
6
|
+
clientSecret: 'client-secret',
|
|
7
|
+
username: 'api-user',
|
|
8
|
+
password: 'api-password',
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const publicAkeneoLookupHost: AkeneoClientDeps['lookupHost'] = async () => [{ address: '93.184.216.34', family: 4 }]
|
|
12
|
+
|
|
13
|
+
const MIB = 1024 * 1024
|
|
14
|
+
const DEFAULT_MEDIA_MAX_BYTES = 25 * MIB
|
|
15
|
+
const OVERSIZED_MEDIA_BYTES = 26 * MIB
|
|
16
|
+
const STREAMED_OVERSIZED_MEDIA_BYTES = 40 * MIB
|
|
17
|
+
const DOWNLOAD_PATH = '/api/rest/v1/media-files/asset-1/download'
|
|
18
|
+
const DOWNLOAD_URL = `https://tenant.cloud.akeneo.com${DOWNLOAD_PATH}`
|
|
19
|
+
const TOKEN_PATH = '/api/oauth/v1/token'
|
|
20
|
+
const PRODUCTS_PATH = '/api/rest/v1/products-uuid'
|
|
21
|
+
const ENV_KEYS = [
|
|
22
|
+
'OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS',
|
|
23
|
+
'OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES',
|
|
24
|
+
'OM_ATTACHMENT_MAX_UPLOAD_MB',
|
|
25
|
+
] as const
|
|
26
|
+
|
|
27
|
+
function jsonResponse(body: unknown, init: ResponseInit = {}): Response {
|
|
28
|
+
return new Response(JSON.stringify(body), {
|
|
29
|
+
status: 200,
|
|
30
|
+
...init,
|
|
31
|
+
headers: { 'content-type': 'application/json', ...(init.headers ?? {}) },
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function tokenResponse(): Response {
|
|
36
|
+
return jsonResponse({ access_token: 'token-123', expires_in: 3600 })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function meteredBody(totalBytes: number, chunkBytes = MIB, fill = 0) {
|
|
40
|
+
const meter = { pulledBytes: 0, cancelled: false }
|
|
41
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
42
|
+
pull(controller) {
|
|
43
|
+
if (meter.pulledBytes >= totalBytes) {
|
|
44
|
+
controller.close()
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
const size = Math.min(chunkBytes, totalBytes - meter.pulledBytes)
|
|
48
|
+
meter.pulledBytes += size
|
|
49
|
+
controller.enqueue(new Uint8Array(size).fill(fill))
|
|
50
|
+
},
|
|
51
|
+
cancel() {
|
|
52
|
+
meter.cancelled = true
|
|
53
|
+
},
|
|
54
|
+
}, { highWaterMark: 0 })
|
|
55
|
+
return { stream, meter }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stalledBody(prefix: Uint8Array = new Uint8Array(16)) {
|
|
59
|
+
return new ReadableStream<Uint8Array>({
|
|
60
|
+
start(controller) {
|
|
61
|
+
controller.enqueue(prefix)
|
|
62
|
+
},
|
|
63
|
+
pull() {
|
|
64
|
+
return new Promise<void>(() => undefined)
|
|
65
|
+
},
|
|
66
|
+
}, { highWaterMark: 0 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function jsonPayloadOfSize(totalBytes: number): string {
|
|
70
|
+
const skeleton = JSON.stringify({ items_count: 0, padding: '' })
|
|
71
|
+
return JSON.stringify({ items_count: 0, padding: 'x'.repeat(totalBytes - skeleton.length) })
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function createClientServing(routes: Record<string, () => Response>) {
|
|
75
|
+
const fetchMock = jest.fn(async (input: RequestInfo | URL) => {
|
|
76
|
+
const url = new URL(String(input))
|
|
77
|
+
if (url.pathname === TOKEN_PATH && !routes[TOKEN_PATH]) return tokenResponse()
|
|
78
|
+
const route = routes[url.pathname]
|
|
79
|
+
if (route) return route()
|
|
80
|
+
return new Response('unexpected', { status: 500 })
|
|
81
|
+
})
|
|
82
|
+
const client = createAkeneoClient(validCredentials, {
|
|
83
|
+
lookupHost: publicAkeneoLookupHost,
|
|
84
|
+
fetchImpl: fetchMock as unknown as typeof fetch,
|
|
85
|
+
})
|
|
86
|
+
return { client, fetchMock }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function settleWithin<T>(promise: Promise<T>, ms: number): Promise<'resolved' | 'timed-out' | 'rejected' | 'still-reading'> {
|
|
90
|
+
return Promise.race([
|
|
91
|
+
promise.then(
|
|
92
|
+
() => 'resolved' as const,
|
|
93
|
+
(error: unknown) => (error instanceof Error && error.name === 'FetchTimeoutError' ? 'timed-out' as const : 'rejected' as const),
|
|
94
|
+
),
|
|
95
|
+
new Promise<'still-reading'>((resolve) => setTimeout(() => resolve('still-reading'), ms).unref()),
|
|
96
|
+
])
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('akeneo client response limits (issues #7233, #7242)', () => {
|
|
100
|
+
const originalEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]))
|
|
101
|
+
|
|
102
|
+
afterEach(() => {
|
|
103
|
+
for (const key of ENV_KEYS) {
|
|
104
|
+
const value = originalEnv[key]
|
|
105
|
+
if (value === undefined) delete process.env[key]
|
|
106
|
+
else process.env[key] = value
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe('media downloads (#7233)', () => {
|
|
111
|
+
it('accepts a chunked media body below the cap and reports its real byte length', async () => {
|
|
112
|
+
const { stream } = meteredBody(3 * MIB)
|
|
113
|
+
const { client } = createClientServing({
|
|
114
|
+
[DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const binary = await client.downloadMediaFile(DOWNLOAD_URL)
|
|
118
|
+
|
|
119
|
+
expect(binary.buffer.byteLength).toBe(3 * MIB)
|
|
120
|
+
expect(binary.contentLength).toBe(3 * MIB)
|
|
121
|
+
expect(binary.contentType).toBe('image/png')
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('accepts a media body of exactly the attachment upload limit', async () => {
|
|
125
|
+
const { stream } = meteredBody(DEFAULT_MEDIA_MAX_BYTES)
|
|
126
|
+
const { client } = createClientServing({
|
|
127
|
+
[DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const binary = await client.downloadMediaFile(DOWNLOAD_URL)
|
|
131
|
+
|
|
132
|
+
expect(binary.buffer.byteLength).toBe(DEFAULT_MEDIA_MAX_BYTES)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('aborts a chunked media body without Content-Length once it passes the cap, without buffering the rest', async () => {
|
|
136
|
+
const { stream, meter } = meteredBody(STREAMED_OVERSIZED_MEDIA_BYTES)
|
|
137
|
+
const { client } = createClientServing({
|
|
138
|
+
[DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({
|
|
142
|
+
name: 'AkeneoResponseTooLargeError',
|
|
143
|
+
limitBytes: DEFAULT_MEDIA_MAX_BYTES,
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(DEFAULT_MEDIA_MAX_BYTES + MIB)
|
|
147
|
+
expect(meter.pulledBytes).toBeLessThan(STREAMED_OVERSIZED_MEDIA_BYTES)
|
|
148
|
+
expect(meter.cancelled).toBe(true)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('rejects a declared Content-Length above the cap before reading the body', async () => {
|
|
152
|
+
const { stream, meter } = meteredBody(OVERSIZED_MEDIA_BYTES)
|
|
153
|
+
const { client } = createClientServing({
|
|
154
|
+
[DOWNLOAD_PATH]: () => new Response(stream, {
|
|
155
|
+
status: 200,
|
|
156
|
+
headers: { 'content-type': 'image/png', 'content-length': String(OVERSIZED_MEDIA_BYTES) },
|
|
157
|
+
}),
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
|
|
161
|
+
|
|
162
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(MIB)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('follows OM_ATTACHMENT_MAX_UPLOAD_MB for the media cap', async () => {
|
|
166
|
+
process.env.OM_ATTACHMENT_MAX_UPLOAD_MB = '1'
|
|
167
|
+
const { stream, meter } = meteredBody(3 * MIB, 256 * 1024)
|
|
168
|
+
const { client } = createClientServing({
|
|
169
|
+
[DOWNLOAD_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'image/png' } }),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
await expect(client.downloadMediaFile(DOWNLOAD_URL)).rejects.toMatchObject({ limitBytes: MIB })
|
|
173
|
+
|
|
174
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
it('reports the bytes actually received, not a misleading Content-Length header', async () => {
|
|
178
|
+
const { client } = createClientServing({
|
|
179
|
+
[DOWNLOAD_PATH]: () => new Response(new Uint8Array(5), {
|
|
180
|
+
status: 200,
|
|
181
|
+
headers: { 'content-type': 'image/png', 'content-length': '1' },
|
|
182
|
+
}),
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
const binary = await client.downloadMediaFile(DOWNLOAD_URL)
|
|
186
|
+
|
|
187
|
+
expect(binary.buffer.byteLength).toBe(5)
|
|
188
|
+
expect(binary.contentLength).toBe(5)
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it('keeps the request deadline running while the media body is consumed', async () => {
|
|
192
|
+
process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
|
|
193
|
+
const { client } = createClientServing({
|
|
194
|
+
[DOWNLOAD_PATH]: () => new Response(stalledBody(), { status: 200, headers: { 'content-type': 'image/png' } }),
|
|
195
|
+
})
|
|
196
|
+
|
|
197
|
+
await expect(settleWithin(client.downloadMediaFile(DOWNLOAD_URL), 1_500)).resolves.toBe('timed-out')
|
|
198
|
+
})
|
|
199
|
+
})
|
|
200
|
+
|
|
201
|
+
describe('JSON and error bodies (#7242)', () => {
|
|
202
|
+
it('reads an ordinary multi-page product listing', async () => {
|
|
203
|
+
const { client, fetchMock } = createClientServing({
|
|
204
|
+
[PRODUCTS_PATH]: () => {
|
|
205
|
+
const pageCalls = fetchMock.mock.calls.filter(([input]) => new URL(String(input)).pathname === PRODUCTS_PATH)
|
|
206
|
+
const isSecondPage = new URL(String(pageCalls[pageCalls.length - 1]?.[0])).searchParams.get('page') === '2'
|
|
207
|
+
return jsonResponse({
|
|
208
|
+
_embedded: { items: [{ uuid: isSecondPage ? 'uuid-2' : 'uuid-1' }] },
|
|
209
|
+
_links: isSecondPage ? {} : { next: { href: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` } },
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
const first = await client.listProducts({ batchSize: 1 })
|
|
215
|
+
const second = await client.listProducts({ batchSize: 1, nextUrl: first.nextUrl })
|
|
216
|
+
|
|
217
|
+
expect(first.items.map((item) => item.uuid)).toEqual(['uuid-1'])
|
|
218
|
+
expect(first.nextUrl).toBe(`https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`)
|
|
219
|
+
expect(second.items.map((item) => item.uuid)).toEqual(['uuid-2'])
|
|
220
|
+
expect(second.nextUrl).toBeNull()
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
it('accepts a JSON body of exactly the configured response limit', async () => {
|
|
224
|
+
process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(64 * 1024)
|
|
225
|
+
const payload = jsonPayloadOfSize(64 * 1024)
|
|
226
|
+
const { client } = createClientServing({
|
|
227
|
+
'/api/rest/v1/system-information': () => new Response(payload, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
expect(Buffer.byteLength(payload)).toBe(64 * 1024)
|
|
231
|
+
await expect(client.getSystemProbe()).resolves.toEqual({ version: null })
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it('aborts a chunked JSON body without Content-Length once it passes the configured limit', async () => {
|
|
235
|
+
process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
|
|
236
|
+
const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
|
|
237
|
+
const { client } = createClientServing({
|
|
238
|
+
[PRODUCTS_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
|
|
242
|
+
.rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError', limitBytes: MIB })
|
|
243
|
+
|
|
244
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
|
|
245
|
+
expect(meter.cancelled).toBe(true)
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
it('rejects a JSON body whose declared Content-Length is above the limit before reading it', async () => {
|
|
249
|
+
process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
|
|
250
|
+
const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
|
|
251
|
+
const { client } = createClientServing({
|
|
252
|
+
[PRODUCTS_PATH]: () => new Response(stream, {
|
|
253
|
+
status: 200,
|
|
254
|
+
headers: { 'content-type': 'application/json', 'content-length': String(8 * MIB) },
|
|
255
|
+
}),
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
|
|
259
|
+
.rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
|
|
260
|
+
|
|
261
|
+
expect(meter.pulledBytes).toBe(0)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
it('bounds the OAuth token response body', async () => {
|
|
265
|
+
process.env.OM_INTEGRATION_AKENEO_MAX_RESPONSE_BYTES = String(MIB)
|
|
266
|
+
const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x20)
|
|
267
|
+
const { client } = createClientServing({
|
|
268
|
+
[TOKEN_PATH]: () => new Response(stream, { status: 200, headers: { 'content-type': 'application/json' } }),
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
await expect(client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }))
|
|
272
|
+
.rejects.toMatchObject({ name: 'AkeneoResponseTooLargeError' })
|
|
273
|
+
|
|
274
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(MIB + 256 * 1024)
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
it('keeps the request deadline running while a JSON page drips', async () => {
|
|
278
|
+
process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
|
|
279
|
+
const { client } = createClientServing({
|
|
280
|
+
[PRODUCTS_PATH]: () => new Response(stalledBody(new TextEncoder().encode('{"_embedded":{"items":[')), {
|
|
281
|
+
status: 200,
|
|
282
|
+
headers: { 'content-type': 'application/json' },
|
|
283
|
+
}),
|
|
284
|
+
})
|
|
285
|
+
|
|
286
|
+
const outcome = await settleWithin(
|
|
287
|
+
client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }),
|
|
288
|
+
1_500,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
expect(outcome).toBe('timed-out')
|
|
292
|
+
})
|
|
293
|
+
|
|
294
|
+
it('keeps the request deadline running while the OAuth token body drips', async () => {
|
|
295
|
+
process.env.OM_INTEGRATION_AKENEO_REQUEST_TIMEOUT_MS = '100'
|
|
296
|
+
const { client } = createClientServing({
|
|
297
|
+
[TOKEN_PATH]: () => new Response(stalledBody(new TextEncoder().encode('{"access_token":')), {
|
|
298
|
+
status: 200,
|
|
299
|
+
headers: { 'content-type': 'application/json' },
|
|
300
|
+
}),
|
|
301
|
+
})
|
|
302
|
+
|
|
303
|
+
const outcome = await settleWithin(
|
|
304
|
+
client.listProducts({ batchSize: 10, nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2` }),
|
|
305
|
+
1_500,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
expect(outcome).toBe('timed-out')
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it('truncates a huge error body instead of buffering all of it', async () => {
|
|
312
|
+
const { stream, meter } = meteredBody(8 * MIB, 256 * 1024, 0x61)
|
|
313
|
+
const { client } = createClientServing({
|
|
314
|
+
[PRODUCTS_PATH]: () => new Response(stream, { status: 500, headers: { 'content-type': 'text/plain' } }),
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const error = await client.listProducts({
|
|
318
|
+
batchSize: 10,
|
|
319
|
+
nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`,
|
|
320
|
+
}).catch((err: unknown) => err as Error)
|
|
321
|
+
|
|
322
|
+
expect(error).toBeInstanceOf(Error)
|
|
323
|
+
expect(error.message.startsWith('Akeneo request failed (500): aaaa')).toBe(true)
|
|
324
|
+
expect(error.message.length).toBeLessThanOrEqual(64 * 1024 + 64)
|
|
325
|
+
expect(meter.pulledBytes).toBeLessThanOrEqual(64 * 1024 + 256 * 1024)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
it('keeps short error bodies intact in the thrown message', async () => {
|
|
329
|
+
const { client } = createClientServing({
|
|
330
|
+
[PRODUCTS_PATH]: () => new Response('{"message":"Forbidden"}', { status: 403 }),
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
await expect(client.listProducts({
|
|
334
|
+
batchSize: 10,
|
|
335
|
+
nextUrl: `https://tenant.cloud.akeneo.com${PRODUCTS_PATH}?page=2`,
|
|
336
|
+
})).rejects.toThrow('Akeneo request failed (403): {"message":"Forbidden"}')
|
|
337
|
+
})
|
|
338
|
+
})
|
|
339
|
+
})
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"sync_akeneo.discovery.locales": "Sprachen",
|
|
53
53
|
"sync_akeneo.discovery.priceKinds": "Katalogpreisarten",
|
|
54
54
|
"sync_akeneo.discovery.rediscovered": "Akeneo-Felder aktualisiert und fehlende Zuordnungen hinzugefügt.",
|
|
55
|
+
"sync_akeneo.errors.mediaStorageRejected": "Akeneo-Medium {code} wurde vom Anhangsspeicher abgelehnt ({reason}).",
|
|
55
56
|
"sync_akeneo.feature.configure": "Akeneo-Synchronisierungseinstellungen konfigurieren",
|
|
56
57
|
"sync_akeneo.feature.view": "Akeneo-Synchronisierungskonfiguration anzeigen",
|
|
57
58
|
"sync_akeneo.firstImport.action": "Ersten vollständigen Import starten",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"sync_akeneo.discovery.locales": "Locales",
|
|
53
53
|
"sync_akeneo.discovery.priceKinds": "Catalog price kinds",
|
|
54
54
|
"sync_akeneo.discovery.rediscovered": "Akeneo fields refreshed and missing mappings added.",
|
|
55
|
+
"sync_akeneo.errors.mediaStorageRejected": "Akeneo media {code} was rejected by attachment storage ({reason}).",
|
|
55
56
|
"sync_akeneo.feature.configure": "Configure Akeneo sync settings",
|
|
56
57
|
"sync_akeneo.feature.view": "View Akeneo sync configuration",
|
|
57
58
|
"sync_akeneo.firstImport.action": "Run the first full import",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"sync_akeneo.discovery.locales": "Idiomas",
|
|
53
53
|
"sync_akeneo.discovery.priceKinds": "Tipos de precios de catálogo",
|
|
54
54
|
"sync_akeneo.discovery.rediscovered": "Campos Akeneo actualizados y asignaciones faltantes agregadas.",
|
|
55
|
+
"sync_akeneo.errors.mediaStorageRejected": "El almacenamiento de adjuntos rechazó el archivo multimedia de Akeneo {code} ({reason}).",
|
|
55
56
|
"sync_akeneo.feature.configure": "Configurar ajustes de sincronización Akeneo",
|
|
56
57
|
"sync_akeneo.feature.view": "Ver configuración de sincronización Akeneo",
|
|
57
58
|
"sync_akeneo.firstImport.action": "Ejecutar la primera importación completa",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"sync_akeneo.discovery.locales": "로케일",
|
|
53
53
|
"sync_akeneo.discovery.priceKinds": "카탈로그 가격 종류",
|
|
54
54
|
"sync_akeneo.discovery.rediscovered": "Akeneo 필드가 새로고침되고 누락된 매핑이 추가되었습니다.",
|
|
55
|
+
"sync_akeneo.errors.mediaStorageRejected": "첨부 파일 저장소가 Akeneo 미디어 {code}을(를) 거부했습니다({reason}).",
|
|
55
56
|
"sync_akeneo.feature.configure": "Akeneo 동기화 설정 구성",
|
|
56
57
|
"sync_akeneo.feature.view": "Akeneo 동기화 구성 보기",
|
|
57
58
|
"sync_akeneo.firstImport.action": "첫 전체 가져오기 실행",
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
"sync_akeneo.discovery.locales": "Lokalizacje",
|
|
53
53
|
"sync_akeneo.discovery.priceKinds": "Rodzaje cen katalogowych",
|
|
54
54
|
"sync_akeneo.discovery.rediscovered": "Pola Akeneo odświeżone i brakujące mapowania dodane.",
|
|
55
|
+
"sync_akeneo.errors.mediaStorageRejected": "Magazyn załączników odrzucił plik multimedialny Akeneo {code} ({reason}).",
|
|
55
56
|
"sync_akeneo.feature.configure": "Konfiguracja ustawień synchronizacji Akeneo",
|
|
56
57
|
"sync_akeneo.feature.view": "Podgląd konfiguracji synchronizacji Akeneo",
|
|
57
58
|
"sync_akeneo.firstImport.action": "Uruchom pierwszy pełny import",
|
|
@@ -6,6 +6,8 @@ import type { CommandBus, CommandRuntimeContext } from '@open-mercato/shared/lib
|
|
|
6
6
|
import { emitCrudSideEffects, setCustomFieldsIfAny } from '@open-mercato/shared/lib/commands/helpers'
|
|
7
7
|
import { withAtomicFlush } from '@open-mercato/shared/lib/commands/flush'
|
|
8
8
|
import { findOneWithDecryption, findWithDecryption } from '@open-mercato/shared/lib/encryption/find'
|
|
9
|
+
import { isCrudHttpError } from '@open-mercato/shared/lib/crud/errors'
|
|
10
|
+
import { resolveTranslations } from '@open-mercato/shared/lib/i18n/server'
|
|
9
11
|
import type { DataMapping } from '@open-mercato/core/modules/data_sync/lib/adapter'
|
|
10
12
|
import type { ExternalIdMappingService } from '@open-mercato/core/modules/data_sync/lib/id-mapping'
|
|
11
13
|
import { SyncExternalIdMapping } from '@open-mercato/core/modules/integrations/data/entities'
|
|
@@ -25,9 +27,13 @@ import { normalizeEntityFieldsetConfig, type CustomFieldsetDefinition } from '@o
|
|
|
25
27
|
import { invalidateDefinitionsCache } from '@open-mercato/core/modules/entities/api/definitions.cache'
|
|
26
28
|
import { Attachment } from '@open-mercato/core/modules/attachments/data/entities'
|
|
27
29
|
import { buildAttachmentFileUrl, buildAttachmentImageUrl, slugifyAttachmentFileName } from '@open-mercato/core/modules/attachments/lib/imageUrls'
|
|
28
|
-
import {
|
|
29
|
-
import {
|
|
30
|
-
|
|
30
|
+
import type { AttachmentService } from '@open-mercato/core/modules/attachments'
|
|
31
|
+
import {
|
|
32
|
+
isScopedAttachmentUploadError,
|
|
33
|
+
type ScopedAttachmentUploadErrorCode,
|
|
34
|
+
type ScopedAttachmentUploadService,
|
|
35
|
+
} from '@open-mercato/core/modules/attachments/lib/scoped-upload-service'
|
|
36
|
+
import type { IntegrationLogService } from '@open-mercato/core/modules/integrations/lib/log-service'
|
|
31
37
|
import { attachmentCrudEvents, attachmentCrudIndexer } from '@open-mercato/core/modules/attachments/lib/crud'
|
|
32
38
|
import { emitCatalogQueryIndexEvent } from '@open-mercato/core/modules/catalog/commands/shared'
|
|
33
39
|
import { SalesChannel } from '@open-mercato/core/modules/sales/data/entities'
|
|
@@ -52,7 +58,7 @@ import {
|
|
|
52
58
|
type AkeneoValues,
|
|
53
59
|
buildDefaultAkeneoMapping,
|
|
54
60
|
} from './shared'
|
|
55
|
-
import type
|
|
61
|
+
import { isAkeneoResponseTooLargeError, type AkeneoClient } from './client'
|
|
56
62
|
import { inferAkeneoProductMapping } from './inference'
|
|
57
63
|
import { normalizeMarkdownText } from './markdown'
|
|
58
64
|
|
|
@@ -61,6 +67,12 @@ type ImportScope = {
|
|
|
61
67
|
tenantId: string
|
|
62
68
|
}
|
|
63
69
|
|
|
70
|
+
const REJECTED_MEDIA_UPLOAD_CODES: ReadonlySet<ScopedAttachmentUploadErrorCode> = new Set([
|
|
71
|
+
'dangerous_executable',
|
|
72
|
+
'max_upload_size',
|
|
73
|
+
'active_content',
|
|
74
|
+
])
|
|
75
|
+
|
|
64
76
|
type UpsertResult = {
|
|
65
77
|
localId: string
|
|
66
78
|
action: 'create' | 'update' | 'skip'
|
|
@@ -2305,42 +2317,64 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2305
2317
|
}
|
|
2306
2318
|
}
|
|
2307
2319
|
|
|
2308
|
-
async function
|
|
2320
|
+
async function recordRejectedMedia(asset: DesiredAsset, reason: string): Promise<void> {
|
|
2321
|
+
const integrationLogService = container.resolve('integrationLogService') as IntegrationLogService
|
|
2322
|
+
await integrationLogService.write({
|
|
2323
|
+
integrationId: 'sync_akeneo',
|
|
2324
|
+
level: 'warn',
|
|
2325
|
+
code: 'sync_akeneo.media_rejected',
|
|
2326
|
+
message: `Akeneo media ${asset.remote.codeOrUrl} was not imported: ${reason}`,
|
|
2327
|
+
scopeEntityType: asset.entityId,
|
|
2328
|
+
scopeEntityId: asset.recordId,
|
|
2329
|
+
payload: {
|
|
2330
|
+
externalId: asset.externalId,
|
|
2331
|
+
remoteCode: asset.remote.codeOrUrl,
|
|
2332
|
+
kind: asset.kind,
|
|
2333
|
+
reason,
|
|
2334
|
+
},
|
|
2335
|
+
}, scope)
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
async function upsertAsset(asset: DesiredAsset): Promise<string | null> {
|
|
2309
2339
|
const existingAttachmentId = await externalIdMappingService.lookupLocalId('sync_akeneo', 'attachment', asset.externalId, scope)
|
|
2310
2340
|
if (existingAttachmentId) return existingAttachmentId
|
|
2311
2341
|
|
|
2312
|
-
|
|
2313
|
-
|
|
2342
|
+
let binary: Awaited<ReturnType<AkeneoClient['downloadMediaFile']>>
|
|
2343
|
+
try {
|
|
2344
|
+
binary = await client.downloadMediaFile(asset.remote.codeOrUrl)
|
|
2345
|
+
} catch (error) {
|
|
2346
|
+
if (!isAkeneoResponseTooLargeError(error)) throw error
|
|
2347
|
+
await recordRejectedMedia(asset, 'max_upload_size')
|
|
2348
|
+
return null
|
|
2349
|
+
}
|
|
2314
2350
|
const fileName = asset.remote.fileNameHint ?? binary.fileName ?? `akeneo-${randomUUID().slice(0, 8)}`
|
|
2315
|
-
const
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
2327
|
-
|
|
2328
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
2338
|
-
|
|
2339
|
-
}
|
|
2340
|
-
em.persist(attachment)
|
|
2341
|
-
await em.flush()
|
|
2351
|
+
const uploadService = container.resolve('attachmentScopedUploadService') as ScopedAttachmentUploadService
|
|
2352
|
+
let attachment: Attachment
|
|
2353
|
+
try {
|
|
2354
|
+
attachment = await uploadService.upload({
|
|
2355
|
+
tenantId: scope.tenantId,
|
|
2356
|
+
organizationId: scope.organizationId,
|
|
2357
|
+
entityId: asset.entityId,
|
|
2358
|
+
recordId: asset.recordId,
|
|
2359
|
+
fileName,
|
|
2360
|
+
declaredMimeType: asset.remote.mimeTypeHint ?? binary.contentType,
|
|
2361
|
+
buffer: binary.buffer,
|
|
2362
|
+
})
|
|
2363
|
+
} catch (error) {
|
|
2364
|
+
if (!isScopedAttachmentUploadError(error)) throw error
|
|
2365
|
+
if (REJECTED_MEDIA_UPLOAD_CODES.has(error.code)) {
|
|
2366
|
+
await recordRejectedMedia(asset, error.code)
|
|
2367
|
+
return null
|
|
2368
|
+
}
|
|
2369
|
+
const { translate } = await resolveTranslations()
|
|
2370
|
+
throw new Error(translate(
|
|
2371
|
+
'sync_akeneo.errors.mediaStorageRejected',
|
|
2372
|
+
'Akeneo media {code} was rejected by attachment storage ({reason}).',
|
|
2373
|
+
{ code: asset.remote.codeOrUrl, reason: error.code },
|
|
2374
|
+
))
|
|
2375
|
+
}
|
|
2342
2376
|
attachment.url = asset.kind === 'image'
|
|
2343
|
-
? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(fileName) })
|
|
2377
|
+
? buildAttachmentImageUrl(attachment.id, { slug: slugifyAttachmentFileName(attachment.fileName) })
|
|
2344
2378
|
: buildAttachmentFileUrl(attachment.id)
|
|
2345
2379
|
attachment.storageMetadata = {
|
|
2346
2380
|
...(attachment.storageMetadata ?? {}),
|
|
@@ -2350,11 +2384,32 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2350
2384
|
remoteCode: asset.remote.codeOrUrl,
|
|
2351
2385
|
}
|
|
2352
2386
|
await em.flush()
|
|
2353
|
-
await emitAttachmentCrudChange('
|
|
2387
|
+
await emitAttachmentCrudChange('updated', attachment)
|
|
2354
2388
|
await externalIdMappingService.storeExternalIdMapping('sync_akeneo', 'attachment', attachment.id, asset.externalId, scope)
|
|
2355
2389
|
return attachment.id
|
|
2356
2390
|
}
|
|
2357
2391
|
|
|
2392
|
+
async function releaseImportedAttachment(attachment: Attachment): Promise<boolean> {
|
|
2393
|
+
const attachmentService = container.resolve('attachmentService') as AttachmentService
|
|
2394
|
+
if (!attachmentService.releaseScoped) {
|
|
2395
|
+
throw new Error('[internal] attachmentService.releaseScoped is not available')
|
|
2396
|
+
}
|
|
2397
|
+
try {
|
|
2398
|
+
await attachmentService.releaseScoped({
|
|
2399
|
+
attachmentId: attachment.id,
|
|
2400
|
+
tenantId: scope.tenantId,
|
|
2401
|
+
organizationId: scope.organizationId,
|
|
2402
|
+
expectedOwner: { entityId: attachment.entityId, recordId: attachment.recordId },
|
|
2403
|
+
expectedAssignment: { type: attachment.entityId, id: attachment.recordId },
|
|
2404
|
+
expectedPartitionCode: attachment.partitionCode,
|
|
2405
|
+
})
|
|
2406
|
+
return true
|
|
2407
|
+
} catch (error) {
|
|
2408
|
+
if (isCrudHttpError(error) && error.status === 409) return false
|
|
2409
|
+
throw error
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2358
2413
|
async function reconcileAttachments(params: {
|
|
2359
2414
|
entityId: string
|
|
2360
2415
|
recordId: string
|
|
@@ -2362,12 +2417,11 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2362
2417
|
reconciliation: AkeneoReconciliationSettings
|
|
2363
2418
|
}): Promise<{ heroAttachmentId: string | null; heroAttachmentUrl: string | null }> {
|
|
2364
2419
|
const desiredExternalIds = new Set(params.desiredAssets.map((asset) => asset.externalId))
|
|
2365
|
-
|
|
2366
|
-
const
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
: null
|
|
2420
|
+
let heroAttachmentId: string | null = null
|
|
2421
|
+
for (const asset of params.desiredAssets) {
|
|
2422
|
+
const attachmentId = await upsertAsset(asset)
|
|
2423
|
+
if (!heroAttachmentId && attachmentId && asset.kind === 'image') heroAttachmentId = attachmentId
|
|
2424
|
+
}
|
|
2371
2425
|
const heroAttachmentUrl = heroAttachmentId ? buildAttachmentImageUrl(heroAttachmentId) : null
|
|
2372
2426
|
|
|
2373
2427
|
const existing = await findWithDecryption(em, Attachment, {
|
|
@@ -2385,8 +2439,7 @@ export async function createAkeneoImporter(client: AkeneoClient, scope: ImportSc
|
|
|
2385
2439
|
? params.reconciliation.deleteMissingMedia
|
|
2386
2440
|
: params.reconciliation.deleteMissingAttachments
|
|
2387
2441
|
if (!shouldDelete || desiredExternalIds.has(externalId)) continue
|
|
2388
|
-
await
|
|
2389
|
-
await em.remove(attachment).flush()
|
|
2442
|
+
if (!(await releaseImportedAttachment(attachment))) continue
|
|
2390
2443
|
await emitAttachmentCrudChange('deleted', attachment)
|
|
2391
2444
|
}
|
|
2392
2445
|
|