@adobe/spacecat-shared-tokowaka-client 1.11.1 → 1.12.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.12.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.11.2...@adobe/spacecat-shared-tokowaka-client-v1.12.0) (2026-03-19)
2
+
3
+ ### Features
4
+
5
+ * **AGENTCOM-466:** switch commerce enrichment mapper to semantic HTML ([#1435](https://github.com/adobe/spacecat-shared/issues/1435)) ([05a8fd9](https://github.com/adobe/spacecat-shared/commit/05a8fd9c8574edcd59317abcebb2ba0b3c3e038c))
6
+
7
+ ## [@adobe/spacecat-shared-tokowaka-client-v1.11.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.11.1...@adobe/spacecat-shared-tokowaka-client-v1.11.2) (2026-03-15)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **deps:** update external fixes ([#1440](https://github.com/adobe/spacecat-shared/issues/1440)) ([d1a583a](https://github.com/adobe/spacecat-shared/commit/d1a583aca6a68378debbc01e3a3e8796f1f228bf))
12
+
1
13
  ## [@adobe/spacecat-shared-tokowaka-client-v1.11.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.11.0...@adobe/spacecat-shared-tokowaka-client-v1.11.1) (2026-03-10)
2
14
 
3
15
  ### Bug Fixes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.11.1",
3
+ "version": "1.12.0",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
@@ -35,8 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@adobe/spacecat-shared-utils": "1.81.1",
38
- "@aws-sdk/client-cloudfront": "3.1004.0",
39
- "@aws-sdk/client-s3": "3.1004.0",
38
+ "@aws-sdk/client-cloudfront": "3.1009.0",
39
+ "@aws-sdk/client-s3": "3.1009.0",
40
40
  "hast-util-from-html": "2.0.3",
41
41
  "mdast-util-from-markdown": "2.0.3",
42
42
  "mdast-util-to-hast": "13.2.1",
@@ -13,19 +13,112 @@
13
13
  import { hasText } from '@adobe/spacecat-shared-utils';
14
14
  import { TARGET_USER_AGENTS_CATEGORIES } from '../constants.js';
15
15
  import BaseOpportunityMapper from './base-mapper.js';
16
+ import { htmlToHast } from '../utils/html-utils.js';
16
17
 
17
18
  const EXCLUDED_FIELDS = new Set([
18
19
  'rationale',
19
20
  ]);
20
21
 
21
- function filterEnrichmentData(enrichmentData) {
22
- const filtered = {};
22
+ // Fields rendered in fixed order at the top of the article.
23
+ // Each entry: CSS class, display label, [source keys in priority order]
24
+ const ORDERED_FIELDS = [
25
+ { cls: 'category', sources: ['facts.facets.category_path', 'category'] },
26
+ { cls: 'description', sources: ['pdp.description_plain', 'description'] },
27
+ { cls: 'features', sources: ['pdp.feature_bullets'] },
28
+ { cls: 'variants', sources: ['facts.variants.summary'] },
29
+ ];
30
+
31
+ function escapeHtml(str) {
32
+ return String(str)
33
+ .replace(/&/g, '&')
34
+ .replace(/</g, '&lt;')
35
+ .replace(/>/g, '&gt;')
36
+ .replace(/"/g, '&quot;');
37
+ }
38
+
39
+ function sanitizeClassName(key) {
40
+ return key
41
+ .replace(/^(pdp\.|facts\.facets\.|facts\.variants\.|facts\.attributes\.)/, '')
42
+ .replace(/[^a-z0-9-]/gi, '-')
43
+ .replace(/-+/g, '-')
44
+ .replace(/^-|-$/g, '')
45
+ .toLowerCase();
46
+ }
47
+
48
+ function renderCategoryPath(value) {
49
+ if (Array.isArray(value)) {
50
+ return escapeHtml(value.join(' \u203A '));
51
+ }
52
+ return escapeHtml(String(value));
53
+ }
54
+
55
+ function renderList(items, cls) {
56
+ const lis = items.map((item) => `<li>${escapeHtml(String(item))}</li>`).join('');
57
+ return `<ul class="${cls}">${lis}</ul>`;
58
+ }
59
+
60
+ function renderValue(key, value, cls) {
61
+ if (value == null) return '';
62
+
63
+ if (key === 'facts.facets.category_path' || key === 'category') {
64
+ return `<p class="${cls}">${renderCategoryPath(value)}</p>`;
65
+ }
66
+
67
+ if (Array.isArray(value)) {
68
+ if (value.length === 0) return '';
69
+ return renderList(value, cls);
70
+ }
71
+
72
+ if (typeof value === 'object') {
73
+ const entries = Object.entries(value).filter(([, v]) => v != null && v !== '');
74
+ if (entries.length === 0) return '';
75
+ const items = entries.map(([k, v]) => `${escapeHtml(k)}: ${escapeHtml(String(v))}`);
76
+ return renderList(items, cls);
77
+ }
78
+
79
+ const str = String(value);
80
+ if (!str) return '';
81
+ return `<p class="${cls}">${escapeHtml(str)}</p>`;
82
+ }
83
+
84
+ function enrichmentToHtml(enrichmentData) {
85
+ const sku = enrichmentData.sku || '';
86
+ const consumed = new Set(['sku', ...EXCLUDED_FIELDS]);
87
+ const parts = [];
88
+
89
+ // Render ordered fields first
90
+ for (const { cls, sources } of ORDERED_FIELDS) {
91
+ let matched = false;
92
+ for (const sourceKey of sources) {
93
+ if (!matched && sourceKey in enrichmentData && enrichmentData[sourceKey] != null) {
94
+ const html = renderValue(sourceKey, enrichmentData[sourceKey], cls);
95
+ if (html) {
96
+ parts.push(html);
97
+ matched = true;
98
+ }
99
+ }
100
+ }
101
+ // Consume all sources in the group so alternatives don't appear in remaining fields
102
+ for (const sourceKey of sources) {
103
+ consumed.add(sourceKey);
104
+ }
105
+ }
106
+
107
+ // Render remaining fields in document order
23
108
  for (const [key, value] of Object.entries(enrichmentData)) {
24
- if (!EXCLUDED_FIELDS.has(key) && value != null) {
25
- filtered[key] = value;
109
+ if (consumed.has(key)) {
110
+ // eslint-disable-next-line no-continue
111
+ continue;
112
+ }
113
+ const cls = sanitizeClassName(key);
114
+ const html = renderValue(key, value, cls);
115
+ if (html) {
116
+ parts.push(html);
26
117
  }
27
118
  }
28
- return filtered;
119
+
120
+ const skuAttr = sku ? ` data-sku="${escapeHtml(sku)}"` : '';
121
+ return `<div data-enrichment="spacecat"${skuAttr}><article>${parts.join('')}</article></div>`;
29
122
  }
30
123
 
31
124
  export default class CommercePageEnrichmentMapper extends BaseOpportunityMapper {
@@ -76,17 +169,16 @@ export default class CommercePageEnrichmentMapper extends BaseOpportunityMapper
76
169
 
77
170
  const data = suggestion.getData();
78
171
  const enrichmentData = JSON.parse(data.patchValue);
79
- const value = filterEnrichmentData(enrichmentData);
172
+ const html = enrichmentToHtml(enrichmentData);
173
+ const value = htmlToHast(html);
80
174
 
81
175
  patches.push({
82
176
  ...this.createBasePatch(suggestion, opportunityId),
83
177
  op: 'appendChild',
84
- selector: 'head',
178
+ selector: 'body',
85
179
  value,
86
- valueFormat: 'json',
180
+ valueFormat: 'hast',
87
181
  target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
88
- tag: 'script',
89
- attrs: { type: 'application/json' },
90
182
  });
91
183
  });
92
184
 
@@ -16,6 +16,43 @@ import { expect } from 'chai';
16
16
  import sinon from 'sinon';
17
17
  import CommercePageEnrichmentMapper from '../../src/mappers/commerce-page-enrichment-mapper.js';
18
18
 
19
+ /**
20
+ * Recursively finds the first HAST element node matching a predicate.
21
+ */
22
+ function findNode(node, predicate) {
23
+ if (predicate(node)) return node;
24
+ if (node.children) {
25
+ for (const child of node.children) {
26
+ const found = findNode(child, predicate);
27
+ if (found) return found;
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ /**
34
+ * Collects all HAST element nodes matching a predicate.
35
+ */
36
+ function findAllNodes(node, predicate) {
37
+ const results = [];
38
+ if (predicate(node)) results.push(node);
39
+ if (node.children) {
40
+ for (const child of node.children) {
41
+ results.push(...findAllNodes(child, predicate));
42
+ }
43
+ }
44
+ return results;
45
+ }
46
+
47
+ /**
48
+ * Extracts concatenated text content from a HAST subtree.
49
+ */
50
+ function textContent(node) {
51
+ if (node.type === 'text') return node.value;
52
+ if (node.children) return node.children.map(textContent).join('');
53
+ return '';
54
+ }
55
+
19
56
  describe('CommercePageEnrichmentMapper', () => {
20
57
  let mapper;
21
58
  let log;
@@ -194,7 +231,7 @@ describe('CommercePageEnrichmentMapper', () => {
194
231
  };
195
232
  }
196
233
 
197
- it('should produce a key:value patch appended to head', () => {
234
+ it('should produce a HAST patch appended to body', () => {
198
235
  const suggestion = makeSuggestion({
199
236
  patchValue: JSON.stringify({
200
237
  sku: 'HT5695',
@@ -215,18 +252,51 @@ describe('CommercePageEnrichmentMapper', () => {
215
252
  const patch = patches[0];
216
253
 
217
254
  expect(patch.op).to.equal('appendChild');
218
- expect(patch.selector).to.equal('head');
219
- expect(patch.valueFormat).to.equal('json');
255
+ expect(patch.selector).to.equal('body');
256
+ expect(patch.valueFormat).to.equal('hast');
220
257
  expect(patch.target).to.equal('ai-bots');
221
- expect(patch.tag).to.equal('script');
222
- expect(patch.attrs).to.deep.equal({ type: 'application/json' });
223
258
  expect(patch.opportunityId).to.equal(opportunityId);
224
259
  expect(patch.suggestionId).to.equal(suggestionId);
225
260
  expect(patch.prerenderRequired).to.be.true;
226
261
  expect(patch.lastUpdated).to.be.a('number');
262
+
263
+ // HAST root
264
+ expect(patch.value).to.be.an('object');
265
+ expect(patch.value.type).to.equal('root');
266
+ expect(patch.value.children).to.be.an('array');
227
267
  });
228
268
 
229
- it('should pass through enrichment data as-is', () => {
269
+ it('should produce HAST with correct wrapper and article structure', () => {
270
+ const suggestion = makeSuggestion({
271
+ patchValue: JSON.stringify({
272
+ sku: 'HT5695',
273
+ name: 'Seat Cover Set',
274
+ brand: 'Lovesac',
275
+ 'pdp.description_plain': 'A great product.',
276
+ }),
277
+ url: 'https://www.lovesac.com/products/seat-cover-set',
278
+ });
279
+
280
+ const patches = mapper.suggestionsToPatches(
281
+ '/products/seat-cover-set',
282
+ [suggestion],
283
+ opportunityId,
284
+ );
285
+
286
+ const { value } = patches[0];
287
+
288
+ // Outer div with data-enrichment="spacecat" and data-sku
289
+ const wrapper = findNode(value, (n) => n.tagName === 'div'
290
+ && n.properties?.dataEnrichment === 'spacecat');
291
+ expect(wrapper).to.exist;
292
+ expect(wrapper.properties.dataSku).to.equal('HT5695');
293
+
294
+ // Article inside the wrapper
295
+ const article = findNode(wrapper, (n) => n.tagName === 'article');
296
+ expect(article).to.exist;
297
+ });
298
+
299
+ it('should render enrichment fields as semantic HTML elements', () => {
230
300
  const suggestion = makeSuggestion({
231
301
  patchValue: JSON.stringify({
232
302
  sku: 'HT5695',
@@ -247,13 +317,66 @@ describe('CommercePageEnrichmentMapper', () => {
247
317
  );
248
318
 
249
319
  const { value } = patches[0];
250
- expect(value.sku).to.equal('HT5695');
251
- expect(value.name).to.equal('Seat Cover Set');
252
- expect(value.brand).to.equal('Lovesac');
253
- expect(value['pdp.description_plain']).to.equal('A great product.');
254
- expect(value.material).to.equal('100% polyester chenille');
255
- expect(value['facts.facets.category_path']).to.deep.equal(['Home', 'Sactionals', 'Covers']);
256
- expect(value.color_family).to.equal('Blue');
320
+ const article = findNode(value, (n) => n.tagName === 'article');
321
+ expect(article).to.exist;
322
+
323
+ // String fields → <p> elements
324
+ const paragraphs = findAllNodes(article, (n) => n.tagName === 'p');
325
+ expect(paragraphs.length).to.be.greaterThan(0);
326
+
327
+ // Category path → rendered with › separator
328
+ const categoryP = paragraphs.find((p) => p.properties?.className?.includes('category'));
329
+ expect(categoryP).to.exist;
330
+ expect(textContent(categoryP)).to.include('›');
331
+
332
+ // Description → <p class="description">
333
+ const descP = paragraphs.find((p) => p.properties?.className?.includes('description'));
334
+ expect(descP).to.exist;
335
+ expect(textContent(descP)).to.equal('A great product.');
336
+ });
337
+
338
+ it('should render array fields as unordered lists', () => {
339
+ const suggestion = makeSuggestion({
340
+ patchValue: JSON.stringify({
341
+ sku: 'HT5695',
342
+ 'pdp.feature_bullets': ['Includes 4 Seats', 'StealthTech eligible'],
343
+ }),
344
+ url: 'https://example.com/page',
345
+ });
346
+
347
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
348
+ const { value } = patches[0];
349
+
350
+ const ul = findNode(value, (n) => n.tagName === 'ul'
351
+ && n.properties?.className?.includes('features'));
352
+ expect(ul).to.exist;
353
+
354
+ const lis = ul.children.filter((c) => c.tagName === 'li');
355
+ expect(lis).to.have.length(2);
356
+ expect(textContent(lis[0])).to.equal('Includes 4 Seats');
357
+ expect(textContent(lis[1])).to.equal('StealthTech eligible');
358
+ });
359
+
360
+ it('should render object fields as lists of key-value entries', () => {
361
+ const suggestion = makeSuggestion({
362
+ patchValue: JSON.stringify({
363
+ sku: 'HT5695',
364
+ variants: { color: 'Blue', size: 'Large' },
365
+ }),
366
+ url: 'https://example.com/page',
367
+ });
368
+
369
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
370
+ const { value } = patches[0];
371
+
372
+ const ul = findNode(value, (n) => n.tagName === 'ul'
373
+ && n.properties?.className?.includes('variants'));
374
+ expect(ul).to.exist;
375
+
376
+ const lis = ul.children.filter((c) => c.tagName === 'li');
377
+ expect(lis).to.have.length(2);
378
+ expect(textContent(lis[0])).to.include('color');
379
+ expect(textContent(lis[0])).to.include('Blue');
257
380
  });
258
381
 
259
382
  it('should handle minimal enrichment data (only sku)', () => {
@@ -269,10 +392,16 @@ describe('CommercePageEnrichmentMapper', () => {
269
392
  );
270
393
 
271
394
  expect(patches).to.have.length(1);
272
- expect(patches[0].value).to.deep.equal({ sku: 'MINIMAL' });
395
+ const { value } = patches[0];
396
+ expect(value.type).to.equal('root');
397
+
398
+ const wrapper = findNode(value, (n) => n.tagName === 'div'
399
+ && n.properties?.dataEnrichment === 'spacecat');
400
+ expect(wrapper).to.exist;
401
+ expect(wrapper.properties.dataSku).to.equal('MINIMAL');
273
402
  });
274
403
 
275
- it('should preserve all enrichment fields without transformation', () => {
404
+ it('should render all enrichment field types correctly', () => {
276
405
  const enrichment = {
277
406
  sku: '4Seats5Sides',
278
407
  name: '4 Seats + 5 Sides Sactional',
@@ -305,7 +434,25 @@ describe('CommercePageEnrichmentMapper', () => {
305
434
  );
306
435
 
307
436
  expect(patches).to.have.length(1);
308
- expect(patches[0].value).to.deep.equal(enrichment);
437
+ const { value } = patches[0];
438
+ const article = findNode(value, (n) => n.tagName === 'article');
439
+ expect(article).to.exist;
440
+
441
+ // Ordered fields rendered: category_path used (not category), description, features, variants
442
+ const allText = textContent(article);
443
+ expect(allText).to.include('Furniture');
444
+ expect(allText).to.include('›');
445
+ expect(allText).to.include('A modular sofa configuration.');
446
+ expect(allText).to.include('Includes 4 Seats');
447
+ expect(allText).to.include('Multiple fabric options');
448
+
449
+ // Remaining fields rendered
450
+ expect(allText).to.include('4 Seats + 5 Sides Sactional');
451
+ expect(allText).to.include('Lovesac');
452
+ expect(allText).to.include('Corded Velvet');
453
+ expect(allText).to.include('Grey');
454
+ expect(allText).to.include('homeowners');
455
+ expect(allText).to.include('modular couch');
309
456
  });
310
457
 
311
458
  it('should skip ineligible suggestions and log warning', () => {
@@ -357,6 +504,7 @@ describe('CommercePageEnrichmentMapper', () => {
357
504
  patchValue: JSON.stringify({
358
505
  sku: 'HT5695',
359
506
  rationale: 'This should not appear in output',
507
+ brand: 'Lovesac',
360
508
  }),
361
509
  url: 'https://example.com/page',
362
510
  });
@@ -367,8 +515,9 @@ describe('CommercePageEnrichmentMapper', () => {
367
515
  opportunityId,
368
516
  );
369
517
 
370
- expect(patches[0].value.rationale).to.be.undefined;
371
- expect(patches[0].value.sku).to.equal('HT5695');
518
+ const allText = textContent(patches[0].value);
519
+ expect(allText).to.not.include('This should not appear in output');
520
+ expect(allText).to.include('Lovesac');
372
521
  });
373
522
 
374
523
  it('should exclude null values from output', () => {
@@ -387,7 +536,146 @@ describe('CommercePageEnrichmentMapper', () => {
387
536
  opportunityId,
388
537
  );
389
538
 
390
- expect(patches[0].value).to.deep.equal({ sku: 'TEST', brand: 'Lovesac' });
539
+ const allText = textContent(patches[0].value);
540
+ expect(allText).to.include('Lovesac');
541
+ // null name should not produce any element
542
+ const article = findNode(patches[0].value, (n) => n.tagName === 'article');
543
+ const nameP = findAllNodes(article, (n) => n.tagName === 'p'
544
+ && n.properties?.className?.includes('name'));
545
+ expect(nameP).to.have.length(0);
546
+ });
547
+
548
+ it('should render string category as plain text with › not needed', () => {
549
+ const suggestion = makeSuggestion({
550
+ patchValue: JSON.stringify({
551
+ sku: 'TEST',
552
+ category: 'Electronics',
553
+ }),
554
+ url: 'https://example.com/page',
555
+ });
556
+
557
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
558
+ const article = findNode(patches[0].value, (n) => n.tagName === 'article');
559
+ const categoryP = findNode(article, (n) => n.tagName === 'p'
560
+ && n.properties?.className?.includes('category'));
561
+ expect(categoryP).to.exist;
562
+ expect(textContent(categoryP)).to.equal('Electronics');
563
+ });
564
+
565
+ it('should skip empty arrays and empty objects', () => {
566
+ const suggestion = makeSuggestion({
567
+ patchValue: JSON.stringify({
568
+ sku: 'TEST',
569
+ 'pdp.feature_bullets': [],
570
+ variants: {},
571
+ brand: 'TestBrand',
572
+ }),
573
+ url: 'https://example.com/page',
574
+ });
575
+
576
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
577
+ const article = findNode(patches[0].value, (n) => n.tagName === 'article');
578
+ const allText = textContent(article);
579
+ expect(allText).to.include('TestBrand');
580
+
581
+ // Empty array and empty object should not produce elements
582
+ const featureUl = findNode(article, (n) => n.tagName === 'ul'
583
+ && n.properties?.className?.includes('features'));
584
+ expect(featureUl).to.not.exist;
585
+ });
586
+
587
+ it('should skip empty string values', () => {
588
+ const suggestion = makeSuggestion({
589
+ patchValue: JSON.stringify({
590
+ sku: 'TEST',
591
+ name: '',
592
+ brand: 'Visible',
593
+ }),
594
+ url: 'https://example.com/page',
595
+ });
596
+
597
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
598
+ const article = findNode(patches[0].value, (n) => n.tagName === 'article');
599
+ const allText = textContent(article);
600
+ expect(allText).to.include('Visible');
601
+
602
+ const nameP = findAllNodes(article, (n) => n.tagName === 'p'
603
+ && n.properties?.className?.includes('name'));
604
+ expect(nameP).to.have.length(0);
605
+ });
606
+
607
+ it('should filter null and empty entries from object values', () => {
608
+ const suggestion = makeSuggestion({
609
+ patchValue: JSON.stringify({
610
+ sku: 'TEST',
611
+ variants: { color: 'Blue', size: null, weight: '' },
612
+ }),
613
+ url: 'https://example.com/page',
614
+ });
615
+
616
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
617
+ const ul = findNode(patches[0].value, (n) => n.tagName === 'ul'
618
+ && n.properties?.className?.includes('variants'));
619
+ expect(ul).to.exist;
620
+ const lis = ul.children.filter((c) => c.tagName === 'li');
621
+ expect(lis).to.have.length(1);
622
+ expect(textContent(lis[0])).to.include('Blue');
623
+ });
624
+
625
+ it('should produce empty article when object has only null entries', () => {
626
+ const suggestion = makeSuggestion({
627
+ patchValue: JSON.stringify({
628
+ sku: 'TEST',
629
+ variants: { color: null, size: '' },
630
+ }),
631
+ url: 'https://example.com/page',
632
+ });
633
+
634
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
635
+ const ul = findNode(patches[0].value, (n) => n.tagName === 'ul'
636
+ && n.properties?.className?.includes('variants'));
637
+ expect(ul).to.not.exist;
638
+ });
639
+
640
+ it('should handle enrichment with no sku', () => {
641
+ const suggestion = makeSuggestion({
642
+ patchValue: JSON.stringify({
643
+ brand: 'NoBrand',
644
+ }),
645
+ url: 'https://example.com/page',
646
+ });
647
+
648
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
649
+ const wrapper = findNode(patches[0].value, (n) => n.tagName === 'div'
650
+ && n.properties?.dataEnrichment === 'spacecat');
651
+ expect(wrapper).to.exist;
652
+ expect(wrapper.properties.dataSku).to.be.undefined;
653
+ });
654
+
655
+ it('should use ordered fields priority (category_path over category)', () => {
656
+ const suggestion = makeSuggestion({
657
+ patchValue: JSON.stringify({
658
+ sku: 'TEST',
659
+ category: 'Simple Category',
660
+ 'facts.facets.category_path': ['Home', 'Furniture'],
661
+ }),
662
+ url: 'https://example.com/page',
663
+ });
664
+
665
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], opportunityId);
666
+ const { value } = patches[0];
667
+ const article = findNode(value, (n) => n.tagName === 'article');
668
+
669
+ // category_path should be used (has priority), not the flat category
670
+ const categoryP = findNode(article, (n) => n.tagName === 'p'
671
+ && n.properties?.className?.includes('category'));
672
+ expect(categoryP).to.exist;
673
+ expect(textContent(categoryP)).to.include('Home');
674
+ expect(textContent(categoryP)).to.include('›');
675
+
676
+ // Flat 'category' should NOT appear since category_path was consumed
677
+ const allText = textContent(article);
678
+ expect(allText).to.not.include('Simple Category');
391
679
  });
392
680
  });
393
681
  });