@adobe/spacecat-shared-tokowaka-client 1.8.0 → 1.10.0
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/CHANGELOG.md +12 -0
- package/package.json +1 -1
- package/src/index.js +66 -13
- package/src/mappers/commerce-page-enrichment-mapper.js +128 -0
- package/src/mappers/mapper-registry.js +2 -0
- package/test/index.test.js +130 -1
- package/test/mappers/commerce-page-enrichment-mapper.test.js +453 -0
- package/test/mappers/mapper-registry.test.js +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [@adobe/spacecat-shared-tokowaka-client-v1.10.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.9.0...@adobe/spacecat-shared-tokowaka-client-v1.10.0) (2026-02-26)
|
|
2
|
+
|
|
3
|
+
### Features
|
|
4
|
+
|
|
5
|
+
* LLMO-2805 Stage config addition api changes ([#1370](https://github.com/adobe/spacecat-shared/issues/1370)) ([0789f4a](https://github.com/adobe/spacecat-shared/commit/0789f4af95433b733c86638a79c184850804dba9))
|
|
6
|
+
|
|
7
|
+
## [@adobe/spacecat-shared-tokowaka-client-v1.9.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.8.0...@adobe/spacecat-shared-tokowaka-client-v1.9.0) (2026-02-24)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **AGENTCOM-266:** add commerce page enrichment mapper for tokowaka edge deployment ([#1369](https://github.com/adobe/spacecat-shared/issues/1369)) ([357f2f4](https://github.com/adobe/spacecat-shared/commit/357f2f44cd8da19272a7638b55870873388840c9))
|
|
12
|
+
|
|
1
13
|
## [@adobe/spacecat-shared-tokowaka-client-v1.8.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.7.8...@adobe/spacecat-shared-tokowaka-client-v1.8.0) (2026-02-18)
|
|
2
14
|
|
|
3
15
|
### Features
|
package/package.json
CHANGED
package/src/index.js
CHANGED
|
@@ -230,11 +230,13 @@ class TokowakaClient {
|
|
|
230
230
|
}
|
|
231
231
|
|
|
232
232
|
/**
|
|
233
|
-
*
|
|
233
|
+
* Internal method to fetch domain-level metaconfig from S3 with metadata
|
|
234
234
|
* @param {string} url - Full URL (used to extract domain)
|
|
235
|
-
* @returns {Promise<Object|null>} -
|
|
235
|
+
* @returns {Promise<Object|null>} - Object with metaconfig and s3Metadata,
|
|
236
|
+
* or null if not found
|
|
237
|
+
* @private
|
|
236
238
|
*/
|
|
237
|
-
async
|
|
239
|
+
async #fetchMetaconfigWithMetadata(url) {
|
|
238
240
|
if (!hasText(url)) {
|
|
239
241
|
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
240
242
|
}
|
|
@@ -254,7 +256,11 @@ class TokowakaClient {
|
|
|
254
256
|
const metaconfig = JSON.parse(bodyContents);
|
|
255
257
|
|
|
256
258
|
this.log.debug(`Successfully fetched metaconfig from s3://${bucketName}/${s3Path} in ${Date.now() - fetchStartTime}ms`);
|
|
257
|
-
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
metaconfig,
|
|
262
|
+
s3Metadata: response.Metadata || {},
|
|
263
|
+
};
|
|
258
264
|
} catch (error) {
|
|
259
265
|
// If metaconfig doesn't exist (NoSuchKey), return null
|
|
260
266
|
if (error.name === 'NoSuchKey' || error.Code === 'NoSuchKey') {
|
|
@@ -268,6 +274,16 @@ class TokowakaClient {
|
|
|
268
274
|
}
|
|
269
275
|
}
|
|
270
276
|
|
|
277
|
+
/**
|
|
278
|
+
* Fetches domain-level metaconfig from S3
|
|
279
|
+
* @param {string} url - Full URL (used to extract domain)
|
|
280
|
+
* @returns {Promise<Object|null>} - Metaconfig object or null if not found
|
|
281
|
+
*/
|
|
282
|
+
async fetchMetaconfig(url) {
|
|
283
|
+
const result = await this.#fetchMetaconfigWithMetadata(url);
|
|
284
|
+
return result?.metaconfig ?? null;
|
|
285
|
+
}
|
|
286
|
+
|
|
271
287
|
/**
|
|
272
288
|
* Generates an API key for Tokowaka based on domain
|
|
273
289
|
* @param {string} domain - Domain name (e.g., 'example.com')
|
|
@@ -289,7 +305,13 @@ class TokowakaClient {
|
|
|
289
305
|
* @param {string} url - Full URL (used to extract domain)
|
|
290
306
|
* @param {string} siteId - Site ID
|
|
291
307
|
* @param {Object} options - Optional configuration
|
|
292
|
-
* @param {boolean} options.enhancements - Whether to enable enhancements
|
|
308
|
+
* @param {boolean} options.enhancements - Whether to enable enhancements
|
|
309
|
+
* (default: true)
|
|
310
|
+
* @param {Object} metadata - Optional S3 user-defined metadata for
|
|
311
|
+
* audit trail and behavior flags
|
|
312
|
+
* @param {string} metadata.lastModifiedBy - User who modified the config
|
|
313
|
+
* @param {boolean} metadata.isStageDomain - Whether this is a staging
|
|
314
|
+
* domain (enables wildcard prerender)
|
|
293
315
|
* @returns {Promise<Object>} - Object with s3Path and metaconfig
|
|
294
316
|
*/
|
|
295
317
|
async createMetaconfig(url, siteId, options = {}, metadata = {}) {
|
|
@@ -318,7 +340,19 @@ class TokowakaClient {
|
|
|
318
340
|
patches: {},
|
|
319
341
|
};
|
|
320
342
|
|
|
321
|
-
|
|
343
|
+
// Handle staging domain with automatic prerender configuration
|
|
344
|
+
const isStageDomain = metadata.isStageDomain === true;
|
|
345
|
+
if (isStageDomain) {
|
|
346
|
+
metaconfig.prerender = { allowList: ['/*'] };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Persist isStageDomain in S3 metadata for future updates
|
|
350
|
+
const s3Metadata = {
|
|
351
|
+
...metadata,
|
|
352
|
+
...(isStageDomain && { isStageDomain: 'true' }),
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
const s3Path = await this.uploadMetaconfig(url, metaconfig, s3Metadata);
|
|
322
356
|
this.log.info(`Created new Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
|
|
323
357
|
|
|
324
358
|
return metaconfig;
|
|
@@ -330,6 +364,11 @@ class TokowakaClient {
|
|
|
330
364
|
* @param {string} url - Full URL (used to extract domain)
|
|
331
365
|
* @param {string} siteId - Site ID
|
|
332
366
|
* @param {Object} options - Optional configuration
|
|
367
|
+
* @param {Object} metadata - Optional S3 user-defined metadata for
|
|
368
|
+
* audit trail and behavior flags
|
|
369
|
+
* @param {string} metadata.lastModifiedBy - User who modified the config
|
|
370
|
+
* @param {boolean} metadata.isStageDomain - Whether this is a staging
|
|
371
|
+
* domain (enables wildcard prerender)
|
|
333
372
|
* @returns {Promise<Object>} - Object with s3Path and metaconfig
|
|
334
373
|
*/
|
|
335
374
|
async updateMetaconfig(url, siteId, options = {}, metadata = {}) {
|
|
@@ -337,10 +376,11 @@ class TokowakaClient {
|
|
|
337
376
|
throw this.#createError('URL is required', HTTP_BAD_REQUEST);
|
|
338
377
|
}
|
|
339
378
|
|
|
340
|
-
const
|
|
341
|
-
if (!
|
|
379
|
+
const raw = await this.#fetchMetaconfigWithMetadata(url);
|
|
380
|
+
if (!raw?.metaconfig) {
|
|
342
381
|
throw this.#createError('Metaconfig does not exist for this URL', HTTP_BAD_REQUEST);
|
|
343
382
|
}
|
|
383
|
+
const { metaconfig: existingMetaconfig, s3Metadata: existingS3Metadata } = raw;
|
|
344
384
|
|
|
345
385
|
if (!hasText(siteId)) {
|
|
346
386
|
throw this.#createError('Site ID is required', HTTP_BAD_REQUEST);
|
|
@@ -356,10 +396,17 @@ class TokowakaClient {
|
|
|
356
396
|
?? existingMetaconfig.forceFail
|
|
357
397
|
?? false;
|
|
358
398
|
|
|
359
|
-
|
|
399
|
+
// Handle staging domain: check from metadata or from existing S3 metadata (S3 lowercases keys)
|
|
400
|
+
const isStageDomain = metadata.isStageDomain === true
|
|
401
|
+
|| existingS3Metadata.isstagedomain === 'true';
|
|
402
|
+
|
|
403
|
+
const hasPrerender = isStageDomain
|
|
404
|
+
|| isNonEmptyObject(options.prerender)
|
|
360
405
|
|| isNonEmptyObject(existingMetaconfig.prerender);
|
|
361
|
-
|
|
362
|
-
|
|
406
|
+
|
|
407
|
+
const prerender = isStageDomain
|
|
408
|
+
? { allowList: ['/*'] }
|
|
409
|
+
: (options.prerender ?? existingMetaconfig.prerender);
|
|
363
410
|
|
|
364
411
|
const metaconfig = {
|
|
365
412
|
...existingMetaconfig,
|
|
@@ -372,8 +419,14 @@ class TokowakaClient {
|
|
|
372
419
|
...(hasPrerender && { prerender }),
|
|
373
420
|
};
|
|
374
421
|
|
|
375
|
-
|
|
376
|
-
|
|
422
|
+
// Persist isStageDomain in S3 metadata for future updates
|
|
423
|
+
const s3Metadata = {
|
|
424
|
+
...metadata,
|
|
425
|
+
...(isStageDomain && { isStageDomain: 'true' }),
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
const uploadedPath = await this.uploadMetaconfig(url, metaconfig, s3Metadata);
|
|
429
|
+
this.log.info(`Updated Tokowaka metaconfig for ${normalizedHostName} at ${uploadedPath}`);
|
|
377
430
|
|
|
378
431
|
return metaconfig;
|
|
379
432
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { hasText } from '@adobe/spacecat-shared-utils';
|
|
14
|
+
import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
|
|
15
|
+
import BaseOpportunityMapper from './base-mapper.js';
|
|
16
|
+
|
|
17
|
+
const SCHEMA_ORG_DIRECT_FIELDS = {
|
|
18
|
+
sku: 'sku',
|
|
19
|
+
name: 'name',
|
|
20
|
+
material: 'material',
|
|
21
|
+
category: 'category',
|
|
22
|
+
color_family: 'color',
|
|
23
|
+
'pdp.description_plain': 'description',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const EXCLUDED_FIELDS = new Set([
|
|
27
|
+
'rationale',
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
function toJsonLd(enrichmentData) {
|
|
31
|
+
const jsonLd = {
|
|
32
|
+
'@context': 'https://schema.org',
|
|
33
|
+
'@type': 'Product',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const additionalProperties = [];
|
|
37
|
+
|
|
38
|
+
for (const [key, value] of Object.entries(enrichmentData)) {
|
|
39
|
+
if (!EXCLUDED_FIELDS.has(key) && value != null) {
|
|
40
|
+
if (key === 'brand' && typeof value === 'string') {
|
|
41
|
+
jsonLd.brand = { '@type': 'Brand', name: value };
|
|
42
|
+
} else if (key === 'facts.facets.category_path' && Array.isArray(value)) {
|
|
43
|
+
jsonLd.category = value.join(' > ');
|
|
44
|
+
} else if (SCHEMA_ORG_DIRECT_FIELDS[key]) {
|
|
45
|
+
jsonLd[SCHEMA_ORG_DIRECT_FIELDS[key]] = value;
|
|
46
|
+
} else {
|
|
47
|
+
additionalProperties.push({
|
|
48
|
+
'@type': 'PropertyValue',
|
|
49
|
+
name: key,
|
|
50
|
+
value,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (additionalProperties.length > 0) {
|
|
57
|
+
jsonLd.additionalProperty = additionalProperties;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return jsonLd;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export default class CommercePageEnrichmentMapper extends BaseOpportunityMapper {
|
|
64
|
+
constructor(log) {
|
|
65
|
+
super(log);
|
|
66
|
+
this.opportunityType = 'commerce-product-page-enrichment';
|
|
67
|
+
this.prerenderRequired = true;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
getOpportunityType() {
|
|
71
|
+
return this.opportunityType;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
requiresPrerender() {
|
|
75
|
+
return this.prerenderRequired;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// eslint-disable-next-line class-methods-use-this
|
|
79
|
+
canDeploy(suggestion) {
|
|
80
|
+
const data = suggestion.getData();
|
|
81
|
+
|
|
82
|
+
if (!hasText(data?.patchValue)) {
|
|
83
|
+
return { eligible: false, reason: 'patchValue is required' };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (!hasText(data?.url)) {
|
|
87
|
+
return { eligible: false, reason: 'url is required' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
JSON.parse(data.patchValue);
|
|
92
|
+
} catch {
|
|
93
|
+
return { eligible: false, reason: 'patchValue must be valid JSON' };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { eligible: true };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
suggestionsToPatches(urlPath, suggestions, opportunityId) {
|
|
100
|
+
const patches = [];
|
|
101
|
+
|
|
102
|
+
suggestions.forEach((suggestion) => {
|
|
103
|
+
const eligibility = this.canDeploy(suggestion);
|
|
104
|
+
if (!eligibility.eligible) {
|
|
105
|
+
this.log.warn(`Commerce page enrichment suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const data = suggestion.getData();
|
|
110
|
+
const enrichmentData = JSON.parse(data.patchValue);
|
|
111
|
+
|
|
112
|
+
const jsonLd = toJsonLd(enrichmentData);
|
|
113
|
+
|
|
114
|
+
patches.push({
|
|
115
|
+
...this.createBasePatch(suggestion, opportunityId),
|
|
116
|
+
op: 'appendChild',
|
|
117
|
+
selector: 'head',
|
|
118
|
+
value: jsonLd,
|
|
119
|
+
valueFormat: 'json',
|
|
120
|
+
target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
|
|
121
|
+
tag: 'script',
|
|
122
|
+
attrs: { type: 'application/ld+json' },
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
return patches;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -17,6 +17,7 @@ import ReadabilityMapper from './readability-mapper.js';
|
|
|
17
17
|
import TocMapper from './toc-mapper.js';
|
|
18
18
|
import GenericMapper from './generic-mapper.js';
|
|
19
19
|
import PrerenderMapper from './prerender-mapper.js';
|
|
20
|
+
import CommercePageEnrichmentMapper from './commerce-page-enrichment-mapper.js';
|
|
20
21
|
import SemanticValueVisibilityMapper from './semantic-value-visibility-mapper.js';
|
|
21
22
|
|
|
22
23
|
/**
|
|
@@ -43,6 +44,7 @@ export default class MapperRegistry {
|
|
|
43
44
|
TocMapper,
|
|
44
45
|
GenericMapper,
|
|
45
46
|
PrerenderMapper,
|
|
47
|
+
CommercePageEnrichmentMapper,
|
|
46
48
|
SemanticValueVisibilityMapper,
|
|
47
49
|
];
|
|
48
50
|
|
package/test/index.test.js
CHANGED
|
@@ -649,6 +649,56 @@ describe('TokowakaClient', () => {
|
|
|
649
649
|
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
650
650
|
expect(uploadCommand.input.Metadata).to.be.undefined;
|
|
651
651
|
});
|
|
652
|
+
|
|
653
|
+
it('should NOT include prerender when isStageDomain is not true in metadata', async () => {
|
|
654
|
+
const siteId = 'site-123';
|
|
655
|
+
const url = 'https://www.example.com/page1';
|
|
656
|
+
const noSuchKeyError = new Error('NoSuchKey');
|
|
657
|
+
noSuchKeyError.name = 'NoSuchKey';
|
|
658
|
+
s3Client.send.onFirstCall().rejects(noSuchKeyError);
|
|
659
|
+
|
|
660
|
+
const result = await client.createMetaconfig(url, siteId);
|
|
661
|
+
|
|
662
|
+
expect(result).to.not.have.property('prerender');
|
|
663
|
+
|
|
664
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
665
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
666
|
+
expect(body).to.not.have.property('prerender');
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
it('should set prerender with allowList when isStageDomain is true in metadata', async () => {
|
|
670
|
+
const siteId = 'site-123';
|
|
671
|
+
const url = 'https://staging.example.com/page1';
|
|
672
|
+
const noSuchKeyError = new Error('NoSuchKey');
|
|
673
|
+
noSuchKeyError.name = 'NoSuchKey';
|
|
674
|
+
s3Client.send.onFirstCall().rejects(noSuchKeyError);
|
|
675
|
+
|
|
676
|
+
const result = await client.createMetaconfig(url, siteId, {}, { isStageDomain: true });
|
|
677
|
+
|
|
678
|
+
expect(result).to.have.property('prerender');
|
|
679
|
+
expect(result.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
680
|
+
|
|
681
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
682
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
683
|
+
expect(body.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
684
|
+
expect(uploadCommand.input.Metadata).to.deep.equal({ isStageDomain: 'true' });
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
it('should NOT set prerender when isStageDomain is false in metadata', async () => {
|
|
688
|
+
const siteId = 'site-123';
|
|
689
|
+
const url = 'https://www.example.com/page1';
|
|
690
|
+
const noSuchKeyError = new Error('NoSuchKey');
|
|
691
|
+
noSuchKeyError.name = 'NoSuchKey';
|
|
692
|
+
s3Client.send.onFirstCall().rejects(noSuchKeyError);
|
|
693
|
+
|
|
694
|
+
const result = await client.createMetaconfig(url, siteId, {}, { isStageDomain: false });
|
|
695
|
+
|
|
696
|
+
expect(result).to.not.have.property('prerender');
|
|
697
|
+
|
|
698
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
699
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
700
|
+
expect(body).to.not.have.property('prerender');
|
|
701
|
+
});
|
|
652
702
|
});
|
|
653
703
|
|
|
654
704
|
describe('updateMetaconfig', () => {
|
|
@@ -661,11 +711,12 @@ describe('TokowakaClient', () => {
|
|
|
661
711
|
};
|
|
662
712
|
|
|
663
713
|
beforeEach(() => {
|
|
664
|
-
// Mock fetchMetaconfig to return existing config
|
|
714
|
+
// Mock fetchMetaconfig to return existing config with metadata
|
|
665
715
|
s3Client.send.onFirstCall().resolves({
|
|
666
716
|
Body: {
|
|
667
717
|
transformToString: sinon.stub().resolves(JSON.stringify(existingMetaconfig)),
|
|
668
718
|
},
|
|
719
|
+
Metadata: {},
|
|
669
720
|
});
|
|
670
721
|
// Mock uploadMetaconfig S3 upload
|
|
671
722
|
s3Client.send.onSecondCall().resolves();
|
|
@@ -1431,6 +1482,84 @@ describe('TokowakaClient', () => {
|
|
|
1431
1482
|
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
1432
1483
|
expect(uploadCommand.input.Metadata).to.be.undefined;
|
|
1433
1484
|
});
|
|
1485
|
+
|
|
1486
|
+
it('should set prerender with allowList when isStageDomain is true in metadata', async () => {
|
|
1487
|
+
const siteId = 'site-456';
|
|
1488
|
+
const url = 'https://staging.example.com';
|
|
1489
|
+
|
|
1490
|
+
const result = await client.updateMetaconfig(url, siteId, {}, { isStageDomain: true });
|
|
1491
|
+
|
|
1492
|
+
expect(result).to.have.property('prerender');
|
|
1493
|
+
expect(result.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
1494
|
+
|
|
1495
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
1496
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
1497
|
+
expect(body.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
1498
|
+
expect(uploadCommand.input.Metadata).to.deep.equal({ isStageDomain: 'true' });
|
|
1499
|
+
});
|
|
1500
|
+
|
|
1501
|
+
it('should override existing prerender when isStageDomain is true in metadata', async () => {
|
|
1502
|
+
const siteId = 'site-456';
|
|
1503
|
+
const url = 'https://staging.example.com';
|
|
1504
|
+
const existingMetaconfigWithPrerender = {
|
|
1505
|
+
...existingMetaconfig,
|
|
1506
|
+
prerender: { allowList: ['/old-path/*'] },
|
|
1507
|
+
};
|
|
1508
|
+
|
|
1509
|
+
s3Client.send.onFirstCall().resolves({
|
|
1510
|
+
Body: {
|
|
1511
|
+
transformToString: sinon.stub().resolves(JSON.stringify(existingMetaconfigWithPrerender)),
|
|
1512
|
+
},
|
|
1513
|
+
Metadata: {},
|
|
1514
|
+
});
|
|
1515
|
+
|
|
1516
|
+
const result = await client.updateMetaconfig(url, siteId, {}, { isStageDomain: true });
|
|
1517
|
+
|
|
1518
|
+
expect(result).to.have.property('prerender');
|
|
1519
|
+
expect(result.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
1520
|
+
|
|
1521
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
1522
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
1523
|
+
expect(body.prerender).to.deep.equal({ allowList: ['/*'] });
|
|
1524
|
+
});
|
|
1525
|
+
|
|
1526
|
+
it('should preserve existing prerender when isStageDomain is not in metadata', async () => {
|
|
1527
|
+
const siteId = 'site-456';
|
|
1528
|
+
const url = 'https://www.example.com';
|
|
1529
|
+
const existingMetaconfigWithPrerender = {
|
|
1530
|
+
...existingMetaconfig,
|
|
1531
|
+
prerender: { allowList: ['/path/*'] },
|
|
1532
|
+
};
|
|
1533
|
+
|
|
1534
|
+
s3Client.send.onFirstCall().resolves({
|
|
1535
|
+
Body: {
|
|
1536
|
+
transformToString: sinon.stub().resolves(JSON.stringify(existingMetaconfigWithPrerender)),
|
|
1537
|
+
},
|
|
1538
|
+
Metadata: {},
|
|
1539
|
+
});
|
|
1540
|
+
|
|
1541
|
+
const result = await client.updateMetaconfig(url, siteId, {});
|
|
1542
|
+
|
|
1543
|
+
expect(result).to.have.property('prerender');
|
|
1544
|
+
expect(result.prerender).to.deep.equal({ allowList: ['/path/*'] });
|
|
1545
|
+
|
|
1546
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
1547
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
1548
|
+
expect(body.prerender).to.deep.equal({ allowList: ['/path/*'] });
|
|
1549
|
+
});
|
|
1550
|
+
|
|
1551
|
+
it('should NOT set prerender when isStageDomain is false in metadata', async () => {
|
|
1552
|
+
const siteId = 'site-456';
|
|
1553
|
+
const url = 'https://www.example.com';
|
|
1554
|
+
|
|
1555
|
+
const result = await client.updateMetaconfig(url, siteId, {}, { isStageDomain: false });
|
|
1556
|
+
|
|
1557
|
+
expect(result).to.not.have.property('prerender');
|
|
1558
|
+
|
|
1559
|
+
const uploadCommand = s3Client.send.secondCall.args[0];
|
|
1560
|
+
const body = JSON.parse(uploadCommand.input.Body);
|
|
1561
|
+
expect(body).to.not.have.property('prerender');
|
|
1562
|
+
});
|
|
1434
1563
|
});
|
|
1435
1564
|
|
|
1436
1565
|
describe('uploadConfig', () => {
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 Adobe. All rights reserved.
|
|
3
|
+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
* you may not use this file except in compliance with the License. You may obtain a copy
|
|
5
|
+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
|
|
6
|
+
*
|
|
7
|
+
* Unless required by applicable law or agreed to in writing, software distributed under
|
|
8
|
+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
|
|
9
|
+
* OF ANY KIND, either express or implied. See the License for the specific language
|
|
10
|
+
* governing permissions and limitations under the License.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/* eslint-env mocha */
|
|
14
|
+
|
|
15
|
+
import { expect } from 'chai';
|
|
16
|
+
import sinon from 'sinon';
|
|
17
|
+
import CommercePageEnrichmentMapper from '../../src/mappers/commerce-page-enrichment-mapper.js';
|
|
18
|
+
|
|
19
|
+
describe('CommercePageEnrichmentMapper', () => {
|
|
20
|
+
let mapper;
|
|
21
|
+
let log;
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
log = {
|
|
25
|
+
debug: sinon.stub(),
|
|
26
|
+
info: sinon.stub(),
|
|
27
|
+
warn: sinon.stub(),
|
|
28
|
+
error: sinon.stub(),
|
|
29
|
+
};
|
|
30
|
+
mapper = new CommercePageEnrichmentMapper(log);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('getOpportunityType', () => {
|
|
34
|
+
it('should return commerce-product-page-enrichment', () => {
|
|
35
|
+
expect(mapper.getOpportunityType()).to.equal('commerce-product-page-enrichment');
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
describe('requiresPrerender', () => {
|
|
40
|
+
it('should return true', () => {
|
|
41
|
+
expect(mapper.requiresPrerender()).to.be.true;
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('allowConfigsWithoutPatch', () => {
|
|
46
|
+
it('should return false', () => {
|
|
47
|
+
expect(mapper.allowConfigsWithoutPatch()).to.be.false;
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('canDeploy', () => {
|
|
52
|
+
const validPatchValue = JSON.stringify({
|
|
53
|
+
sku: 'HT5695',
|
|
54
|
+
'pdp.description_plain': 'A product description.',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return eligible for suggestion with patchValue and url', () => {
|
|
58
|
+
const suggestion = {
|
|
59
|
+
getData: () => ({
|
|
60
|
+
patchValue: validPatchValue,
|
|
61
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({ eligible: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should return eligible when transformRules is absent', () => {
|
|
69
|
+
const suggestion = {
|
|
70
|
+
getData: () => ({
|
|
71
|
+
patchValue: validPatchValue,
|
|
72
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
73
|
+
format: 'json',
|
|
74
|
+
tag: 'div',
|
|
75
|
+
}),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({ eligible: true });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should return eligible when transformRules is null', () => {
|
|
82
|
+
const suggestion = {
|
|
83
|
+
getData: () => ({
|
|
84
|
+
patchValue: validPatchValue,
|
|
85
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
86
|
+
transformRules: null,
|
|
87
|
+
}),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({ eligible: true });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('should return ineligible when patchValue is missing', () => {
|
|
94
|
+
const suggestion = {
|
|
95
|
+
getData: () => ({
|
|
96
|
+
url: 'https://example.com/page',
|
|
97
|
+
}),
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
101
|
+
eligible: false,
|
|
102
|
+
reason: 'patchValue is required',
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should return ineligible when patchValue is empty string', () => {
|
|
107
|
+
const suggestion = {
|
|
108
|
+
getData: () => ({
|
|
109
|
+
patchValue: '',
|
|
110
|
+
url: 'https://example.com/page',
|
|
111
|
+
}),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
115
|
+
eligible: false,
|
|
116
|
+
reason: 'patchValue is required',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('should return ineligible when url is missing', () => {
|
|
121
|
+
const suggestion = {
|
|
122
|
+
getData: () => ({
|
|
123
|
+
patchValue: validPatchValue,
|
|
124
|
+
}),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
128
|
+
eligible: false,
|
|
129
|
+
reason: 'url is required',
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should return ineligible when url is empty string', () => {
|
|
134
|
+
const suggestion = {
|
|
135
|
+
getData: () => ({
|
|
136
|
+
patchValue: validPatchValue,
|
|
137
|
+
url: '',
|
|
138
|
+
}),
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
142
|
+
eligible: false,
|
|
143
|
+
reason: 'url is required',
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should return ineligible when patchValue is not valid JSON', () => {
|
|
148
|
+
const suggestion = {
|
|
149
|
+
getData: () => ({
|
|
150
|
+
patchValue: 'not valid json {{{',
|
|
151
|
+
url: 'https://example.com/page',
|
|
152
|
+
}),
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
156
|
+
eligible: false,
|
|
157
|
+
reason: 'patchValue must be valid JSON',
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return ineligible when data is null', () => {
|
|
162
|
+
const suggestion = {
|
|
163
|
+
getData: () => null,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
167
|
+
eligible: false,
|
|
168
|
+
reason: 'patchValue is required',
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it('should return ineligible when data is undefined', () => {
|
|
173
|
+
const suggestion = {
|
|
174
|
+
getData: () => undefined,
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
expect(mapper.canDeploy(suggestion)).to.deep.equal({
|
|
178
|
+
eligible: false,
|
|
179
|
+
reason: 'patchValue is required',
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
describe('suggestionsToPatches', () => {
|
|
185
|
+
const opportunityId = '03189e92-ac0f-4436-965a-0a7b55fc4101';
|
|
186
|
+
const suggestionId = '2fec9113-98e8-40b4-90e0-10aa9ce45abc';
|
|
187
|
+
const updatedAt = '2026-02-17T09:48:58.974Z';
|
|
188
|
+
|
|
189
|
+
function makeSuggestion(data, id = suggestionId) {
|
|
190
|
+
return {
|
|
191
|
+
getId: () => id,
|
|
192
|
+
getUpdatedAt: () => updatedAt,
|
|
193
|
+
getData: () => data,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
it('should produce a JSON-LD patch appended to head', () => {
|
|
198
|
+
const suggestion = makeSuggestion({
|
|
199
|
+
patchValue: JSON.stringify({
|
|
200
|
+
sku: 'HT5695',
|
|
201
|
+
name: 'Seat Cover Set',
|
|
202
|
+
brand: 'Lovesac',
|
|
203
|
+
'pdp.description_plain': 'A great product.',
|
|
204
|
+
}),
|
|
205
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const patches = mapper.suggestionsToPatches(
|
|
209
|
+
'/products/seat-cover-set',
|
|
210
|
+
[suggestion],
|
|
211
|
+
opportunityId,
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
expect(patches).to.have.length(1);
|
|
215
|
+
const patch = patches[0];
|
|
216
|
+
|
|
217
|
+
expect(patch.op).to.equal('appendChild');
|
|
218
|
+
expect(patch.selector).to.equal('head');
|
|
219
|
+
expect(patch.valueFormat).to.equal('json');
|
|
220
|
+
expect(patch.target).to.equal('ai-bots');
|
|
221
|
+
expect(patch.tag).to.equal('script');
|
|
222
|
+
expect(patch.attrs).to.deep.equal({ type: 'application/ld+json' });
|
|
223
|
+
expect(patch.opportunityId).to.equal(opportunityId);
|
|
224
|
+
expect(patch.suggestionId).to.equal(suggestionId);
|
|
225
|
+
expect(patch.prerenderRequired).to.be.true;
|
|
226
|
+
expect(patch.lastUpdated).to.be.a('number');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('should produce valid schema.org Product JSON-LD', () => {
|
|
230
|
+
const suggestion = makeSuggestion({
|
|
231
|
+
patchValue: JSON.stringify({
|
|
232
|
+
sku: 'HT5695',
|
|
233
|
+
name: 'Seat Cover Set',
|
|
234
|
+
brand: 'Lovesac',
|
|
235
|
+
'pdp.description_plain': 'A great product.',
|
|
236
|
+
material: '100% polyester chenille',
|
|
237
|
+
'facts.facets.category_path': ['Home', 'Sactionals', 'Covers'],
|
|
238
|
+
color_family: 'Blue',
|
|
239
|
+
}),
|
|
240
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
const patches = mapper.suggestionsToPatches(
|
|
244
|
+
'/products/seat-cover-set',
|
|
245
|
+
[suggestion],
|
|
246
|
+
opportunityId,
|
|
247
|
+
);
|
|
248
|
+
|
|
249
|
+
const jsonLd = patches[0].value;
|
|
250
|
+
expect(jsonLd['@context']).to.equal('https://schema.org');
|
|
251
|
+
expect(jsonLd['@type']).to.equal('Product');
|
|
252
|
+
expect(jsonLd.sku).to.equal('HT5695');
|
|
253
|
+
expect(jsonLd.name).to.equal('Seat Cover Set');
|
|
254
|
+
expect(jsonLd.brand).to.deep.equal({ '@type': 'Brand', name: 'Lovesac' });
|
|
255
|
+
expect(jsonLd.description).to.equal('A great product.');
|
|
256
|
+
expect(jsonLd.material).to.equal('100% polyester chenille');
|
|
257
|
+
expect(jsonLd.category).to.equal('Home > Sactionals > Covers');
|
|
258
|
+
expect(jsonLd.color).to.equal('Blue');
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it('should map unmapped fields to additionalProperty', () => {
|
|
262
|
+
const suggestion = makeSuggestion({
|
|
263
|
+
patchValue: JSON.stringify({
|
|
264
|
+
sku: 'HT5695',
|
|
265
|
+
'pdp.feature_bullets': ['Bullet 1', 'Bullet 2'],
|
|
266
|
+
'facts.attributes.fabric_care': 'Machine washable',
|
|
267
|
+
audience_tags: ['homeowners', 'families'],
|
|
268
|
+
}),
|
|
269
|
+
url: 'https://www.lovesac.com/products/seat-cover-set',
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
const patches = mapper.suggestionsToPatches(
|
|
273
|
+
'/products/seat-cover-set',
|
|
274
|
+
[suggestion],
|
|
275
|
+
opportunityId,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
const jsonLd = patches[0].value;
|
|
279
|
+
expect(jsonLd.additionalProperty).to.be.an('array');
|
|
280
|
+
|
|
281
|
+
const bulletsProp = jsonLd.additionalProperty.find((p) => p.name === 'pdp.feature_bullets');
|
|
282
|
+
expect(bulletsProp).to.exist;
|
|
283
|
+
expect(bulletsProp.value).to.deep.equal(['Bullet 1', 'Bullet 2']);
|
|
284
|
+
|
|
285
|
+
const careProp = jsonLd.additionalProperty.find((p) => p.name === 'facts.attributes.fabric_care');
|
|
286
|
+
expect(careProp).to.exist;
|
|
287
|
+
expect(careProp.value).to.equal('Machine washable');
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should handle minimal enrichment data (only sku)', () => {
|
|
291
|
+
const suggestion = makeSuggestion({
|
|
292
|
+
patchValue: JSON.stringify({ sku: 'MINIMAL' }),
|
|
293
|
+
url: 'https://example.com/products/minimal',
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
const patches = mapper.suggestionsToPatches(
|
|
297
|
+
'/products/minimal',
|
|
298
|
+
[suggestion],
|
|
299
|
+
opportunityId,
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
expect(patches).to.have.length(1);
|
|
303
|
+
const jsonLd = patches[0].value;
|
|
304
|
+
expect(jsonLd['@context']).to.equal('https://schema.org');
|
|
305
|
+
expect(jsonLd['@type']).to.equal('Product');
|
|
306
|
+
expect(jsonLd.sku).to.equal('MINIMAL');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should handle rich enrichment data with all field types', () => {
|
|
310
|
+
const suggestion = makeSuggestion({
|
|
311
|
+
patchValue: JSON.stringify({
|
|
312
|
+
sku: '4Seats5Sides',
|
|
313
|
+
name: '4 Seats + 5 Sides Sactional',
|
|
314
|
+
category: 'Sectional / Modular Sofa',
|
|
315
|
+
brand: 'Lovesac',
|
|
316
|
+
'pdp.description_plain': 'A modular sofa configuration.',
|
|
317
|
+
'pdp.feature_bullets': ['Includes 4 Seats', 'StealthTech eligible'],
|
|
318
|
+
'facts.facets.category_path': ['Furniture', 'Sectionals', 'Sactionals'],
|
|
319
|
+
'facts.variants.summary': ['Multiple fabric options'],
|
|
320
|
+
material: 'Corded Velvet',
|
|
321
|
+
dimensions_or_capacity: '35" W x 29" D',
|
|
322
|
+
care_instructions: ['Machine washable'],
|
|
323
|
+
color_family: 'Grey',
|
|
324
|
+
audience_tags: ['homeowners'],
|
|
325
|
+
use_context: ['living room'],
|
|
326
|
+
style_tags: ['modern'],
|
|
327
|
+
keyword_synonyms: ['modular couch'],
|
|
328
|
+
persona_phrases: ['for families'],
|
|
329
|
+
}),
|
|
330
|
+
url: 'https://www.lovesac.com/products/4-seats-5-sides',
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
const patches = mapper.suggestionsToPatches(
|
|
334
|
+
'/products/4-seats-5-sides',
|
|
335
|
+
[suggestion],
|
|
336
|
+
opportunityId,
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
expect(patches).to.have.length(1);
|
|
340
|
+
const jsonLd = patches[0].value;
|
|
341
|
+
expect(jsonLd.sku).to.equal('4Seats5Sides');
|
|
342
|
+
expect(jsonLd.name).to.equal('4 Seats + 5 Sides Sactional');
|
|
343
|
+
expect(jsonLd.category).to.equal('Furniture > Sectionals > Sactionals');
|
|
344
|
+
expect(jsonLd.brand).to.deep.equal({ '@type': 'Brand', name: 'Lovesac' });
|
|
345
|
+
expect(jsonLd.description).to.equal('A modular sofa configuration.');
|
|
346
|
+
expect(jsonLd.material).to.equal('Corded Velvet');
|
|
347
|
+
expect(jsonLd.color).to.equal('Grey');
|
|
348
|
+
expect(jsonLd.additionalProperty).to.be.an('array');
|
|
349
|
+
expect(jsonLd.additionalProperty.length).to.be.greaterThan(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should skip ineligible suggestions and log warning', () => {
|
|
353
|
+
const eligible = makeSuggestion({
|
|
354
|
+
patchValue: JSON.stringify({ sku: 'GOOD' }),
|
|
355
|
+
url: 'https://example.com/good',
|
|
356
|
+
}, 'good-id');
|
|
357
|
+
|
|
358
|
+
const ineligible = makeSuggestion({
|
|
359
|
+
patchValue: '',
|
|
360
|
+
url: 'https://example.com/bad',
|
|
361
|
+
}, 'bad-id');
|
|
362
|
+
|
|
363
|
+
const patches = mapper.suggestionsToPatches(
|
|
364
|
+
'/test',
|
|
365
|
+
[eligible, ineligible],
|
|
366
|
+
opportunityId,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
expect(patches).to.have.length(1);
|
|
370
|
+
expect(patches[0].suggestionId).to.equal('good-id');
|
|
371
|
+
expect(log.warn.calledOnce).to.be.true;
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should produce multiple patches for multiple suggestions', () => {
|
|
375
|
+
const s1 = makeSuggestion({
|
|
376
|
+
patchValue: JSON.stringify({ sku: 'SKU1' }),
|
|
377
|
+
url: 'https://example.com/page',
|
|
378
|
+
}, 'id-1');
|
|
379
|
+
|
|
380
|
+
const s2 = makeSuggestion({
|
|
381
|
+
patchValue: JSON.stringify({ sku: 'SKU2' }),
|
|
382
|
+
url: 'https://example.com/page',
|
|
383
|
+
}, 'id-2');
|
|
384
|
+
|
|
385
|
+
const patches = mapper.suggestionsToPatches(
|
|
386
|
+
'/page',
|
|
387
|
+
[s1, s2],
|
|
388
|
+
opportunityId,
|
|
389
|
+
);
|
|
390
|
+
|
|
391
|
+
expect(patches).to.have.length(2);
|
|
392
|
+
expect(patches[0].suggestionId).to.equal('id-1');
|
|
393
|
+
expect(patches[1].suggestionId).to.equal('id-2');
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
it('should not include rationale field in JSON-LD output', () => {
|
|
397
|
+
const suggestion = makeSuggestion({
|
|
398
|
+
patchValue: JSON.stringify({
|
|
399
|
+
sku: 'HT5695',
|
|
400
|
+
rationale: 'This should not appear in output',
|
|
401
|
+
}),
|
|
402
|
+
url: 'https://example.com/page',
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
const patches = mapper.suggestionsToPatches(
|
|
406
|
+
'/page',
|
|
407
|
+
[suggestion],
|
|
408
|
+
opportunityId,
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
const jsonLd = patches[0].value;
|
|
412
|
+
expect(jsonLd.rationale).to.be.undefined;
|
|
413
|
+
const rationaleProp = (jsonLd.additionalProperty || []).find((p) => p.name === 'rationale');
|
|
414
|
+
expect(rationaleProp).to.be.undefined;
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('should join category_path array with > separator', () => {
|
|
418
|
+
const suggestion = makeSuggestion({
|
|
419
|
+
patchValue: JSON.stringify({
|
|
420
|
+
sku: 'TEST',
|
|
421
|
+
'facts.facets.category_path': ['Home', 'Furniture', 'Sofas'],
|
|
422
|
+
}),
|
|
423
|
+
url: 'https://example.com/page',
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
const patches = mapper.suggestionsToPatches(
|
|
427
|
+
'/page',
|
|
428
|
+
[suggestion],
|
|
429
|
+
opportunityId,
|
|
430
|
+
);
|
|
431
|
+
|
|
432
|
+
expect(patches[0].value.category).to.equal('Home > Furniture > Sofas');
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('should use category field directly when facts.facets.category_path is absent', () => {
|
|
436
|
+
const suggestion = makeSuggestion({
|
|
437
|
+
patchValue: JSON.stringify({
|
|
438
|
+
sku: 'TEST',
|
|
439
|
+
category: 'Sectional / Modular Sofa',
|
|
440
|
+
}),
|
|
441
|
+
url: 'https://example.com/page',
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
const patches = mapper.suggestionsToPatches(
|
|
445
|
+
'/page',
|
|
446
|
+
[suggestion],
|
|
447
|
+
opportunityId,
|
|
448
|
+
);
|
|
449
|
+
|
|
450
|
+
expect(patches[0].value.category).to.equal('Sectional / Modular Sofa');
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
});
|
|
@@ -18,6 +18,7 @@ import sinon from 'sinon';
|
|
|
18
18
|
import sinonChai from 'sinon-chai';
|
|
19
19
|
import MapperRegistry from '../../src/mappers/mapper-registry.js';
|
|
20
20
|
import HeadingsMapper from '../../src/mappers/headings-mapper.js';
|
|
21
|
+
import CommercePageEnrichmentMapper from '../../src/mappers/commerce-page-enrichment-mapper.js';
|
|
21
22
|
import BaseOpportunityMapper from '../../src/mappers/base-mapper.js';
|
|
22
23
|
|
|
23
24
|
use(sinonChai);
|
|
@@ -115,6 +116,12 @@ describe('MapperRegistry', () => {
|
|
|
115
116
|
expect(mapper).to.be.instanceOf(HeadingsMapper);
|
|
116
117
|
});
|
|
117
118
|
|
|
119
|
+
it('should return commerce page enrichment mapper', () => {
|
|
120
|
+
const mapper = registry.getMapper('commerce-product-page-enrichment');
|
|
121
|
+
|
|
122
|
+
expect(mapper).to.be.instanceOf(CommercePageEnrichmentMapper);
|
|
123
|
+
});
|
|
124
|
+
|
|
118
125
|
it('should return null for unsupported opportunity type', () => {
|
|
119
126
|
const mapper = registry.getMapper('unsupported-type');
|
|
120
127
|
|
|
@@ -143,6 +150,7 @@ describe('MapperRegistry', () => {
|
|
|
143
150
|
|
|
144
151
|
expect(types).to.be.an('array');
|
|
145
152
|
expect(types).to.include('headings');
|
|
153
|
+
expect(types).to.include('commerce-product-page-enrichment');
|
|
146
154
|
});
|
|
147
155
|
|
|
148
156
|
it('should include custom registered mappers', () => {
|