@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.8.0",
3
+ "version": "1.10.0",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -230,11 +230,13 @@ class TokowakaClient {
230
230
  }
231
231
 
232
232
  /**
233
- * Fetches domain-level metaconfig from S3
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>} - Metaconfig object or null if not found
235
+ * @returns {Promise<Object|null>} - Object with metaconfig and s3Metadata,
236
+ * or null if not found
237
+ * @private
236
238
  */
237
- async fetchMetaconfig(url) {
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
- return metaconfig;
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 (default: true)
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
- const s3Path = await this.uploadMetaconfig(url, metaconfig, metadata);
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 existingMetaconfig = await this.fetchMetaconfig(url);
341
- if (!existingMetaconfig) {
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
- const hasPrerender = isNonEmptyObject(options.prerender)
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
- const prerender = options.prerender
362
- ?? existingMetaconfig.prerender;
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
- const s3Path = await this.uploadMetaconfig(url, metaconfig, metadata);
376
- this.log.info(`Updated Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
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
 
@@ -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', () => {