@adobe/spacecat-shared-tokowaka-client 1.0.2 → 1.0.3

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.
@@ -0,0 +1,1264 @@
1
+ /*
2
+ * Copyright 2025 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 FaqMapper from '../../src/mappers/faq-mapper.js';
17
+
18
+ describe('FaqMapper', () => {
19
+ let mapper;
20
+ let log;
21
+
22
+ beforeEach(() => {
23
+ log = {
24
+ debug: () => {},
25
+ info: () => {},
26
+ warn: () => {},
27
+ error: () => {},
28
+ };
29
+ mapper = new FaqMapper(log);
30
+ });
31
+
32
+ describe('getOpportunityType', () => {
33
+ it('should return faq', () => {
34
+ expect(mapper.getOpportunityType()).to.equal('faq');
35
+ });
36
+ });
37
+
38
+ describe('requiresPrerender', () => {
39
+ it('should return true', () => {
40
+ expect(mapper.requiresPrerender()).to.be.true;
41
+ });
42
+ });
43
+
44
+ describe('canDeploy', () => {
45
+ it('should return eligible for valid FAQ suggestion', () => {
46
+ const suggestion = {
47
+ getData: () => ({
48
+ item: {
49
+ question: 'Is this valid?',
50
+ answer: 'Yes, it is.',
51
+ },
52
+ url: 'https://www.example.com/page',
53
+ transformRules: {
54
+ action: 'appendChild',
55
+ selector: 'main',
56
+ },
57
+ }),
58
+ };
59
+
60
+ const result = mapper.canDeploy(suggestion);
61
+
62
+ expect(result).to.deep.equal({ eligible: true });
63
+ });
64
+
65
+ it('should return ineligible when item.question/answer is missing', () => {
66
+ const suggestion = {
67
+ getData: () => ({
68
+ transformRules: {
69
+ action: 'appendChild',
70
+ selector: 'main',
71
+ },
72
+ }),
73
+ };
74
+
75
+ const result = mapper.canDeploy(suggestion);
76
+
77
+ expect(result).to.deep.equal({
78
+ eligible: false,
79
+ reason: 'item.question and item.answer are required',
80
+ });
81
+ });
82
+
83
+ it('should return ineligible when transformRules are missing', () => {
84
+ const suggestion = {
85
+ getData: () => ({
86
+ item: {
87
+ question: 'Is this valid?',
88
+ answer: 'Yes, it is.',
89
+ },
90
+ }),
91
+ };
92
+
93
+ const result = mapper.canDeploy(suggestion);
94
+
95
+ expect(result).to.deep.equal({
96
+ eligible: false,
97
+ reason: 'transformRules is required',
98
+ });
99
+ });
100
+
101
+ it('should return ineligible when transformRules action is invalid', () => {
102
+ const suggestion = {
103
+ getData: () => ({
104
+ item: {
105
+ question: 'Question?',
106
+ answer: 'Answer.',
107
+ },
108
+ transformRules: {
109
+ action: 'replace',
110
+ selector: 'main',
111
+ },
112
+ }),
113
+ };
114
+
115
+ const result = mapper.canDeploy(suggestion);
116
+
117
+ expect(result).to.deep.equal({
118
+ eligible: false,
119
+ reason: 'transformRules.action must be insertAfter, insertBefore, or appendChild',
120
+ });
121
+ });
122
+
123
+ it('should return ineligible when transformRules selector is missing', () => {
124
+ const suggestion = {
125
+ getData: () => ({
126
+ item: {
127
+ question: 'Question?',
128
+ answer: 'Answer.',
129
+ },
130
+ transformRules: {
131
+ action: 'appendChild',
132
+ },
133
+ }),
134
+ };
135
+
136
+ const result = mapper.canDeploy(suggestion);
137
+
138
+ expect(result).to.deep.equal({
139
+ eligible: false,
140
+ reason: 'transformRules.selector is required',
141
+ });
142
+ });
143
+
144
+ it('should return ineligible when data is null', () => {
145
+ const suggestion = {
146
+ getData: () => null,
147
+ };
148
+
149
+ const result = mapper.canDeploy(suggestion);
150
+
151
+ expect(result).to.deep.equal({
152
+ eligible: false,
153
+ reason: 'item.question and item.answer are required',
154
+ });
155
+ });
156
+
157
+ it('should accept insertAfter as valid action', () => {
158
+ const suggestion = {
159
+ getData: () => ({
160
+ item: {
161
+ question: 'Question?',
162
+ answer: 'Answer.',
163
+ },
164
+ url: 'https://www.example.com/page',
165
+ transformRules: {
166
+ action: 'insertAfter',
167
+ selector: 'main',
168
+ },
169
+ }),
170
+ };
171
+
172
+ const result = mapper.canDeploy(suggestion);
173
+
174
+ expect(result).to.deep.equal({ eligible: true });
175
+ });
176
+
177
+ it('should accept insertBefore as valid action', () => {
178
+ const suggestion = {
179
+ getData: () => ({
180
+ item: {
181
+ question: 'Question?',
182
+ answer: 'Answer.',
183
+ },
184
+ url: 'https://www.example.com/page',
185
+ transformRules: {
186
+ action: 'insertBefore',
187
+ selector: 'main',
188
+ },
189
+ }),
190
+ };
191
+
192
+ const result = mapper.canDeploy(suggestion);
193
+
194
+ expect(result).to.deep.equal({ eligible: true });
195
+ });
196
+
197
+ it('should return ineligible when URL is invalid', () => {
198
+ const suggestion = {
199
+ getData: () => ({
200
+ item: {
201
+ question: 'Question?',
202
+ answer: 'Answer.',
203
+ },
204
+ url: 'not-a-valid-url',
205
+ transformRules: {
206
+ action: 'appendChild',
207
+ selector: 'main',
208
+ },
209
+ }),
210
+ };
211
+
212
+ const result = mapper.canDeploy(suggestion);
213
+ expect(result.eligible).to.be.false;
214
+ expect(result.reason).to.include('not a valid URL');
215
+ });
216
+
217
+ it('should return ineligible when shouldOptimize is false', () => {
218
+ const suggestion = {
219
+ getData: () => ({
220
+ item: {
221
+ question: 'Question?',
222
+ answer: 'Answer.',
223
+ },
224
+ url: 'https://www.example.com/page',
225
+ shouldOptimize: false,
226
+ transformRules: {
227
+ action: 'appendChild',
228
+ selector: 'main',
229
+ },
230
+ }),
231
+ };
232
+
233
+ const result = mapper.canDeploy(suggestion);
234
+ expect(result).to.deep.equal({
235
+ eligible: false,
236
+ reason: 'shouldOptimize flag is false',
237
+ });
238
+ });
239
+
240
+ it('should return eligible when shouldOptimize is true', () => {
241
+ const suggestion = {
242
+ getData: () => ({
243
+ item: {
244
+ question: 'Question?',
245
+ answer: 'Answer.',
246
+ },
247
+ url: 'https://www.example.com/page',
248
+ shouldOptimize: true,
249
+ transformRules: {
250
+ action: 'appendChild',
251
+ selector: 'main',
252
+ },
253
+ }),
254
+ };
255
+
256
+ const result = mapper.canDeploy(suggestion);
257
+ expect(result).to.deep.equal({ eligible: true });
258
+ });
259
+ });
260
+
261
+ describe('suggestionToPatch', () => {
262
+ it('should throw error when called directly', () => {
263
+ const suggestion = {
264
+ getId: () => 'sugg-test',
265
+ getData: () => ({
266
+ item: { question: 'Q?', answer: 'A.' },
267
+ url: 'https://example.com',
268
+ transformRules: { action: 'appendChild', selector: 'main' },
269
+ }),
270
+ };
271
+
272
+ expect(() => mapper.suggestionToPatch(suggestion, 'opp-123')).to.throw('FAQ mapper does not support suggestionToPatch, use suggestionsToPatches instead');
273
+ });
274
+
275
+ it('should create patch with HAST value from markdown', () => {
276
+ const suggestion = {
277
+ getId: () => 'sugg-faq-123',
278
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
279
+ getData: () => ({
280
+ item: {
281
+ question: 'Is Bulk better than myprotein?',
282
+ answer: 'Yes, because of **better value**.',
283
+ },
284
+ url: 'https://www.example.com/page',
285
+ headingText: 'FAQs',
286
+ transformRules: {
287
+ action: 'appendChild',
288
+ selector: 'main',
289
+ },
290
+ }),
291
+ };
292
+
293
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-faq-123', null);
294
+
295
+ // Should create 2 patches: heading + FAQ
296
+ expect(patches).to.be.an('array');
297
+ expect(patches.length).to.equal(2);
298
+
299
+ // First patch: heading (no suggestionId)
300
+ const headingPatch = patches[0];
301
+ expect(headingPatch.opportunityId).to.equal('opp-faq-123');
302
+ expect(headingPatch.suggestionId).to.be.undefined;
303
+ expect(headingPatch.op).to.equal('appendChild');
304
+ expect(headingPatch.selector).to.equal('main');
305
+ expect(headingPatch.value.tagName).to.equal('h2');
306
+ expect(headingPatch.value.children[0].value).to.equal('FAQs');
307
+
308
+ // Second patch: FAQ item
309
+ const faqPatch = patches[1];
310
+ expect(faqPatch.opportunityId).to.equal('opp-faq-123');
311
+ expect(faqPatch.suggestionId).to.equal('sugg-faq-123');
312
+ expect(faqPatch.op).to.equal('appendChild');
313
+ expect(faqPatch.selector).to.equal('main');
314
+ expect(faqPatch.valueFormat).to.equal('hast');
315
+ expect(faqPatch.prerenderRequired).to.be.true;
316
+ expect(faqPatch.lastUpdated).to.be.a('number');
317
+
318
+ // Verify FAQ HAST structure: <div><h3>question</h3>answer</div>
319
+ expect(faqPatch.value).to.be.an('object');
320
+ expect(faqPatch.value.type).to.equal('element');
321
+ expect(faqPatch.value.tagName).to.equal('div');
322
+ expect(faqPatch.value.children).to.be.an('array');
323
+ expect(faqPatch.value.children[0].tagName).to.equal('h3');
324
+ });
325
+
326
+ it('should convert FAQ markdown with headings and lists to HAST', () => {
327
+ const suggestion1 = {
328
+ getId: () => 'sugg-faq-1',
329
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
330
+ getData: () => ({
331
+ item: {
332
+ question: 'Is Bulk better than myprotein?',
333
+ answer: `Bulk offers several advantages:
334
+
335
+ 1. **Better Value for Money**: High-quality products at competitive prices.
336
+ 2. **Wider Selection**: Products for diverse fitness goals.
337
+ 3. **Unique Product Ranges**: Simplified product choices.`,
338
+ },
339
+ url: 'https://www.example.com/page',
340
+ headingText: 'FAQs',
341
+ transformRules: {
342
+ action: 'appendChild',
343
+ selector: 'main',
344
+ },
345
+ }),
346
+ };
347
+
348
+ const patches = mapper.suggestionsToPatches('/page', [suggestion1], 'opp-faq-complex', null);
349
+
350
+ expect(patches.length).to.equal(2); // heading + FAQ
351
+
352
+ // Check heading patch
353
+ expect(patches[0].value.tagName).to.equal('h2');
354
+
355
+ // Check FAQ patch
356
+ const faqPatch = patches[1];
357
+ expect(faqPatch).to.exist;
358
+ expect(faqPatch.value.type).to.equal('element');
359
+ expect(faqPatch.value.tagName).to.equal('div');
360
+ expect(faqPatch.value.children).to.be.an('array');
361
+
362
+ // Verify structure: div > [h3, ...answer content]
363
+ const h3 = faqPatch.value.children[0];
364
+ expect(h3.tagName).to.equal('h3');
365
+ expect(h3.children[0].value).to.equal('Is Bulk better than myprotein?');
366
+
367
+ // The rest should be answer content (paragraph, list, etc.)
368
+ expect(faqPatch.value.children.length).to.be.greaterThan(1);
369
+ });
370
+
371
+ it('should handle markdown with bold text', () => {
372
+ const suggestion = {
373
+ getId: () => 'sugg-faq-bold',
374
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
375
+ getData: () => ({
376
+ item: {
377
+ question: 'Question?',
378
+ answer: 'This is **bold** text.',
379
+ },
380
+ url: 'https://www.example.com/page',
381
+ headingText: 'FAQs',
382
+ transformRules: {
383
+ action: 'appendChild',
384
+ selector: 'main',
385
+ },
386
+ }),
387
+ };
388
+
389
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-faq-bold', null);
390
+
391
+ expect(patches.length).to.equal(2); // heading + FAQ
392
+
393
+ // Check FAQ patch (second one)
394
+ const faqPatch = patches[1];
395
+ expect(faqPatch).to.exist;
396
+ expect(faqPatch.value.type).to.equal('element');
397
+ expect(faqPatch.value.tagName).to.equal('div');
398
+
399
+ // Structure: div > [h3, ...answer]
400
+ expect(faqPatch.value.children[0].tagName).to.equal('h3');
401
+
402
+ // Find the paragraph in the answer
403
+ const paragraph = faqPatch.value.children.find((child) => child.type === 'element' && child.tagName === 'p');
404
+ expect(paragraph).to.exist;
405
+ expect(paragraph.children).to.be.an('array');
406
+
407
+ // Should contain strong elements
408
+ const hasStrong = paragraph.children.some((child) => child.type === 'element' && child.tagName === 'strong');
409
+ expect(hasStrong).to.be.true;
410
+ });
411
+
412
+ it('should return null when item.question/answer is missing', () => {
413
+ const suggestion = {
414
+ getId: () => 'sugg-invalid',
415
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
416
+ getData: () => ({
417
+ transformRules: {
418
+ action: 'appendChild',
419
+ selector: 'main',
420
+ },
421
+ }),
422
+ };
423
+
424
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-invalid', null);
425
+
426
+ expect(patches).to.be.an('array');
427
+ expect(patches.length).to.equal(0);
428
+ });
429
+
430
+ it('should return null when transformRules are incomplete', () => {
431
+ const suggestion = {
432
+ getId: () => 'sugg-invalid-2',
433
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
434
+ getData: () => ({
435
+ item: {
436
+ question: 'Question?',
437
+ answer: 'Answer.',
438
+ },
439
+ transformRules: {
440
+ selector: 'main',
441
+ },
442
+ }),
443
+ };
444
+
445
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-invalid-2', null);
446
+
447
+ expect(patches).to.be.an('array');
448
+ expect(patches.length).to.equal(0);
449
+ });
450
+
451
+ it('should handle markdown parsing errors gracefully', () => {
452
+ let errorCount = 0;
453
+ const errorLog = {
454
+ debug: () => {},
455
+ info: () => {},
456
+ warn: () => {},
457
+ error: () => { errorCount += 1; },
458
+ };
459
+
460
+ const errorMapper = new FaqMapper(errorLog);
461
+
462
+ // Override buildFaqItemHast to throw an error
463
+ const originalBuildFaqItemHast = errorMapper.buildFaqItemHast;
464
+ errorMapper.buildFaqItemHast = () => {
465
+ throw new Error('Markdown parsing failed');
466
+ };
467
+
468
+ const suggestion = {
469
+ getId: () => 'sugg-error',
470
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
471
+ getData: () => ({
472
+ item: {
473
+ question: 'Question?',
474
+ answer: 'Answer.',
475
+ },
476
+ url: 'https://www.example.com/page',
477
+ headingText: 'FAQs',
478
+ transformRules: {
479
+ action: 'appendChild',
480
+ selector: 'main',
481
+ },
482
+ }),
483
+ };
484
+
485
+ const patches = errorMapper.suggestionsToPatches('/page', [suggestion], 'opp-error', null);
486
+
487
+ // Should only create heading patch, FAQ item should fail gracefully
488
+ expect(patches).to.be.an('array');
489
+ expect(patches.length).to.equal(1); // Only heading
490
+ expect(patches[0].value.tagName).to.equal('h2'); // Heading patch
491
+ expect(errorCount).to.be.greaterThan(0);
492
+
493
+ // Restore original method
494
+ errorMapper.buildFaqItemHast = originalBuildFaqItemHast;
495
+ });
496
+
497
+ it('should handle real-world FAQ example from user', () => {
498
+ const question1 = 'Is Bulk better than myprotein?';
499
+ const answer1 = `Bulk offers several advantages over MyProtein in sports nutrition:
500
+
501
+ 1. **Better Value for Money**: Bulk provides high-quality products at competitive prices, highlighting products like their Pure Whey Protein™, Europe's best value whey protein.
502
+ 2. **Wider Selection**: Bulk's range of products caters to diverse fitness goals, including weight loss, muscle building, and performance improvement, making it broader than MyProtein.
503
+ 3. **Unique Product Ranges**: Bulk simplifies product choices with four distinct ranges—Pure Series™, Complete Series™, Pro Series™, and Active Foods™.
504
+ 4. **Customer Satisfaction**: Bulk emphasizes strong customer service and boasts a higher Trustpilot rating compared to MyProtein, indicating better customer trust.
505
+ 5. **Superior Product Formulation**: Popular products, such as Elevate™ pre-workout and Complete Greens™, are noted for their quality and pricing compared to MyProtein's offerings.
506
+
507
+ Overall, Bulk positions itself as a better choice for sports nutrition through its focus on value, variety, innovation, and customer satisfaction.`;
508
+
509
+ const suggestion = {
510
+ getId: () => 'sugg-faq-real',
511
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
512
+ getData: () => ({
513
+ item: {
514
+ question: question1,
515
+ answer: answer1,
516
+ },
517
+ headingText: 'FAQs',
518
+ transformRules: {
519
+ action: 'appendChild',
520
+ selector: 'main',
521
+ },
522
+ url: 'https://www.bulk.com/uk',
523
+ }),
524
+ };
525
+
526
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-faq-real', null);
527
+
528
+ expect(patches.length).to.equal(2); // heading + FAQ
529
+
530
+ // Check heading patch
531
+ expect(patches[0].value.tagName).to.equal('h2');
532
+
533
+ // Check FAQ patch
534
+ const faqPatch = patches[1];
535
+ expect(faqPatch).to.exist;
536
+ expect(faqPatch.op).to.equal('appendChild');
537
+ expect(faqPatch.selector).to.equal('main');
538
+ expect(faqPatch.valueFormat).to.equal('hast');
539
+ expect(faqPatch.value.type).to.equal('element');
540
+ expect(faqPatch.value.tagName).to.equal('div');
541
+ expect(faqPatch.value.children).to.be.an('array');
542
+
543
+ // Verify structure: div > [h3, ...answer content]
544
+ expect(faqPatch.value.children[0].tagName).to.equal('h3');
545
+ });
546
+ });
547
+
548
+ describe('suggestionsToPatches', () => {
549
+ it('should create individual patches for multiple FAQ suggestions', () => {
550
+ const suggestions = [
551
+ {
552
+ getId: () => 'sugg-faq-1',
553
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
554
+ getData: () => ({
555
+ url: 'https://www.example.com/page',
556
+ headingText: 'FAQs',
557
+ item: {
558
+ question: 'What is your return policy?',
559
+ answer: 'You can return items within 30 days.',
560
+ },
561
+ transformRules: {
562
+ action: 'appendChild',
563
+ selector: 'main',
564
+ },
565
+ }),
566
+ },
567
+ {
568
+ getId: () => 'sugg-faq-2',
569
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
570
+ getData: () => ({
571
+ url: 'https://www.example.com/page',
572
+ headingText: 'FAQs',
573
+ item: {
574
+ question: 'Do you ship internationally?',
575
+ answer: 'Yes, we ship to over 100 countries.',
576
+ },
577
+ transformRules: {
578
+ action: 'appendChild',
579
+ selector: 'main',
580
+ },
581
+ }),
582
+ },
583
+ ];
584
+
585
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
586
+
587
+ expect(patches).to.be.an('array');
588
+ expect(patches.length).to.equal(3); // heading + 2 FAQs
589
+
590
+ // First patch: heading (no suggestionId)
591
+ const headingPatch = patches[0];
592
+ expect(headingPatch.opportunityId).to.equal('opp-faq-123');
593
+ expect(headingPatch.suggestionId).to.be.undefined;
594
+ expect(headingPatch.op).to.equal('appendChild');
595
+ expect(headingPatch.selector).to.equal('main');
596
+ expect(headingPatch.value.tagName).to.equal('h2');
597
+
598
+ // Second patch: first FAQ
599
+ const firstFaqPatch = patches[1];
600
+ expect(firstFaqPatch.opportunityId).to.equal('opp-faq-123');
601
+ expect(firstFaqPatch.suggestionId).to.equal('sugg-faq-1');
602
+ expect(firstFaqPatch.op).to.equal('appendChild');
603
+ expect(firstFaqPatch.selector).to.equal('main');
604
+ expect(firstFaqPatch.value.tagName).to.equal('div');
605
+
606
+ // Third patch: second FAQ
607
+ const secondFaqPatch = patches[2];
608
+ expect(secondFaqPatch.opportunityId).to.equal('opp-faq-123');
609
+ expect(secondFaqPatch.suggestionId).to.equal('sugg-faq-2');
610
+ expect(secondFaqPatch.op).to.equal('appendChild');
611
+ expect(secondFaqPatch.selector).to.equal('main');
612
+ expect(secondFaqPatch.value.tagName).to.equal('div');
613
+
614
+ // Verify HAST contains both questions
615
+ const hastString1 = JSON.stringify(firstFaqPatch.value);
616
+ const hastString2 = JSON.stringify(secondFaqPatch.value);
617
+ expect(hastString1).to.include('What is your return policy?');
618
+ expect(hastString2).to.include('Do you ship internationally?');
619
+ });
620
+
621
+ it('should handle single FAQ suggestion', () => {
622
+ const suggestions = [
623
+ {
624
+ getId: () => 'sugg-faq-1',
625
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
626
+ getData: () => ({
627
+ url: 'https://www.example.com/page',
628
+ headingText: 'FAQs',
629
+ item: {
630
+ question: 'What is your return policy?',
631
+ answer: 'You can return items within 30 days.',
632
+ },
633
+ transformRules: {
634
+ action: 'appendChild',
635
+ selector: 'main',
636
+ },
637
+ }),
638
+ },
639
+ ];
640
+
641
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
642
+
643
+ expect(patches).to.be.an('array');
644
+ expect(patches.length).to.equal(2); // heading + 1 FAQ
645
+ expect(patches[0].suggestionId).to.be.undefined; // heading
646
+ expect(patches[1].suggestionId).to.equal('sugg-faq-1'); // FAQ
647
+ });
648
+
649
+ it('should filter out ineligible suggestions', () => {
650
+ const suggestions = [
651
+ {
652
+ getId: () => 'sugg-faq-1',
653
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
654
+ getData: () => ({
655
+ url: 'https://www.example.com/page',
656
+ headingText: 'FAQs',
657
+ item: {
658
+ question: 'Valid question?',
659
+ answer: 'Valid answer.',
660
+ },
661
+ transformRules: {
662
+ action: 'appendChild',
663
+ selector: 'main',
664
+ },
665
+ }),
666
+ },
667
+ {
668
+ getId: () => 'sugg-faq-2',
669
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
670
+ getData: () => ({
671
+ url: 'https://www.example.com/page',
672
+ // Missing item - should be filtered out
673
+ transformRules: {
674
+ action: 'appendChild',
675
+ selector: 'main',
676
+ },
677
+ }),
678
+ },
679
+ ];
680
+
681
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
682
+
683
+ expect(patches.length).to.equal(2); // heading + 1 valid FAQ
684
+ expect(patches[0].suggestionId).to.be.undefined; // heading
685
+ expect(patches[1].suggestionId).to.equal('sugg-faq-1'); // FAQ
686
+ });
687
+
688
+ it('should return empty array when all suggestions are ineligible', () => {
689
+ const suggestions = [
690
+ {
691
+ getId: () => 'sugg-faq-1',
692
+ getData: () => ({
693
+ // Missing transformRules
694
+ item: {
695
+ question: 'Question?',
696
+ answer: 'Answer.',
697
+ },
698
+ }),
699
+ },
700
+ ];
701
+
702
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
703
+
704
+ expect(patches).to.be.an('array');
705
+ expect(patches.length).to.equal(0);
706
+ });
707
+
708
+ it('should return empty array for empty suggestions', () => {
709
+ const patches = mapper.suggestionsToPatches('/page', [], 'opp-faq-123', null);
710
+ expect(patches).to.be.an('array');
711
+ expect(patches.length).to.equal(0);
712
+ });
713
+
714
+ it('should handle suggestions with invalid URLs in allOpportunitySuggestions', () => {
715
+ const suggestions = [
716
+ {
717
+ getId: () => 'sugg-faq-new',
718
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
719
+ getData: () => ({
720
+ url: 'https://www.example.com/page',
721
+ headingText: 'FAQs',
722
+ item: {
723
+ question: 'Q?',
724
+ answer: 'A.',
725
+ },
726
+ transformRules: {
727
+ action: 'appendChild',
728
+ selector: 'main',
729
+ },
730
+ }),
731
+ },
732
+ ];
733
+
734
+ const allOpportunitySuggestions = [
735
+ {
736
+ getId: () => 'sugg-deployed-1',
737
+ getData: () => ({
738
+ url: 'invalid-url', // Invalid URL should be filtered out
739
+ item: {
740
+ question: 'Old Q?',
741
+ answer: 'Old A.',
742
+ },
743
+ tokowakaDeployed: 1704884400000,
744
+ transformRules: {
745
+ action: 'appendChild',
746
+ selector: 'main',
747
+ },
748
+ }),
749
+ },
750
+ ];
751
+
752
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', allOpportunitySuggestions);
753
+
754
+ expect(patches).to.be.an('array');
755
+ expect(patches.length).to.equal(2); // heading + FAQ
756
+ expect(patches[0].suggestionId).to.be.undefined; // heading
757
+ expect(patches[1].suggestionId).to.equal('sugg-faq-new'); // FAQ
758
+ });
759
+
760
+ it('should use correct updatedAt timestamp for each patch', () => {
761
+ const suggestions = [
762
+ {
763
+ getId: () => 'sugg-faq-1',
764
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
765
+ getData: () => ({
766
+ url: 'https://www.example.com/page',
767
+ headingText: 'FAQs',
768
+ item: {
769
+ question: 'Q1?',
770
+ answer: 'A1',
771
+ },
772
+ transformRules: {
773
+ action: 'appendChild',
774
+ selector: 'main',
775
+ },
776
+ }),
777
+ },
778
+ {
779
+ getId: () => 'sugg-faq-2',
780
+ getUpdatedAt: () => '2025-01-15T12:00:00.000Z',
781
+ getData: () => ({
782
+ url: 'https://www.example.com/page',
783
+ headingText: 'FAQs',
784
+ item: {
785
+ question: 'Q2?',
786
+ answer: 'A2',
787
+ },
788
+ transformRules: {
789
+ action: 'appendChild',
790
+ selector: 'main',
791
+ },
792
+ }),
793
+ },
794
+ ];
795
+
796
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
797
+
798
+ const expectedTimestamp1 = new Date('2025-01-15T10:00:00.000Z').getTime();
799
+ const expectedTimestamp2 = new Date('2025-01-15T12:00:00.000Z').getTime();
800
+
801
+ expect(patches.length).to.equal(3); // heading + 2 FAQs
802
+ // Heading uses Date.now(), so just check it exists
803
+ expect(patches[0].lastUpdated).to.be.a('number');
804
+ // FAQ patches use suggestion timestamps
805
+ expect(patches[1].lastUpdated).to.equal(expectedTimestamp1);
806
+ expect(patches[2].lastUpdated).to.equal(expectedTimestamp2);
807
+ });
808
+
809
+ it('should use Date.now() when getUpdatedAt returns null', () => {
810
+ const suggestions = [
811
+ {
812
+ getId: () => 'sugg-faq-1',
813
+ getUpdatedAt: () => null, // No updatedAt
814
+ getData: () => ({
815
+ url: 'https://www.example.com/page',
816
+ headingText: 'FAQs',
817
+ item: {
818
+ question: 'Q1?',
819
+ answer: 'A1',
820
+ },
821
+ transformRules: {
822
+ action: 'appendChild',
823
+ selector: 'main',
824
+ },
825
+ }),
826
+ },
827
+ ];
828
+
829
+ const beforeTime = Date.now();
830
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
831
+ const afterTime = Date.now();
832
+
833
+ expect(patches.length).to.equal(2); // heading + FAQ
834
+ // Both heading and FAQ should use Date.now()
835
+ expect(patches[0].lastUpdated).to.be.at.least(beforeTime);
836
+ expect(patches[0].lastUpdated).to.be.at.most(afterTime);
837
+ expect(patches[1].lastUpdated).to.be.at.least(beforeTime);
838
+ expect(patches[1].lastUpdated).to.be.at.most(afterTime);
839
+ });
840
+
841
+ it('should handle real-world FAQ structure from user example', () => {
842
+ const suggestion = {
843
+ getId: () => '5ea1c4b1-dd5a-42e5-ad97-35cf8cc03cb9',
844
+ getUpdatedAt: () => '2025-11-05T17:02:37.741Z',
845
+ getData: () => ({
846
+ topic: 'modifier pdf',
847
+ transformRules: {
848
+ action: 'appendChild',
849
+ selector: 'main',
850
+ },
851
+ item: {
852
+ answerSuitabilityReason: 'The answer provides clear instructions...',
853
+ questionRelevanceReason: 'The question is directly related...',
854
+ question: 'Comment modifier un PDF déjà existant ?',
855
+ answer: 'Pour modifier un PDF existant avec Adobe Acrobat, vous pouvez utiliser soit l\'éditeur en ligne...',
856
+ sources: [
857
+ 'https://www.adobe.com/in/acrobat/features/modify-pdfs.html',
858
+ ],
859
+ },
860
+ headingText: 'FAQs',
861
+ shouldOptimize: true,
862
+ url: 'https://www.adobe.com/fr/acrobat/online/pdf-editor.html',
863
+ }),
864
+ };
865
+
866
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-faq-123', null);
867
+
868
+ expect(patches).to.be.an('array');
869
+ expect(patches.length).to.equal(2); // heading + FAQ
870
+
871
+ // Check heading
872
+ expect(patches[0].value.tagName).to.equal('h2');
873
+
874
+ // Check FAQ
875
+ const faqPatch = patches[1];
876
+ expect(faqPatch.op).to.equal('appendChild');
877
+ expect(faqPatch.selector).to.equal('main');
878
+ expect(faqPatch.valueFormat).to.equal('hast');
879
+
880
+ const hastString = JSON.stringify(faqPatch.value);
881
+ expect(hastString).to.include('Comment modifier un PDF');
882
+ });
883
+
884
+ it('should handle existing config parameter', () => {
885
+ const suggestions = [
886
+ {
887
+ getId: () => 'sugg-faq-new',
888
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
889
+ getData: () => ({
890
+ url: 'https://www.example.com/page',
891
+ headingText: 'FAQs',
892
+ item: {
893
+ question: 'New question?',
894
+ answer: 'New answer.',
895
+ },
896
+ transformRules: {
897
+ action: 'appendChild',
898
+ selector: 'main',
899
+ },
900
+ }),
901
+ },
902
+ ];
903
+
904
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
905
+
906
+ expect(patches).to.be.an('array');
907
+ expect(patches.length).to.equal(2); // heading + FAQ
908
+ });
909
+
910
+ it('should handle existing config with no existing patches for URL', () => {
911
+ const suggestions = [
912
+ {
913
+ getId: () => 'sugg-faq-new',
914
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
915
+ getData: () => ({
916
+ url: 'https://www.example.com/page',
917
+ headingText: 'FAQs',
918
+ item: {
919
+ question: 'New question?',
920
+ answer: 'New answer.',
921
+ },
922
+ transformRules: {
923
+ action: 'appendChild',
924
+ selector: 'main',
925
+ },
926
+ }),
927
+ },
928
+ ];
929
+
930
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
931
+
932
+ expect(patches).to.be.an('array');
933
+ expect(patches.length).to.equal(2); // heading + FAQ
934
+ });
935
+
936
+ it('should handle error when checking existing config', () => {
937
+ const suggestions = [
938
+ {
939
+ getId: () => 'sugg-faq-new',
940
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
941
+ getData: () => ({
942
+ url: 'https://www.example.com/page',
943
+ headingText: 'FAQs',
944
+ item: {
945
+ question: 'New question?',
946
+ answer: 'New answer.',
947
+ },
948
+ transformRules: {
949
+ action: 'appendChild',
950
+ selector: 'main',
951
+ },
952
+ }),
953
+ },
954
+ ];
955
+
956
+ // Should handle error gracefully and still create patch
957
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
958
+
959
+ expect(patches).to.be.an('array');
960
+ expect(patches.length).to.equal(2); // heading + FAQ
961
+ });
962
+
963
+ it('should handle null URL when checking existing config', () => {
964
+ const suggestions = [
965
+ {
966
+ getId: () => 'sugg-faq-new',
967
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
968
+ getData: () => ({
969
+ url: 'https://www.example.com/page',
970
+ headingText: 'FAQs',
971
+ item: {
972
+ question: 'New question?',
973
+ answer: 'New answer.',
974
+ },
975
+ transformRules: {
976
+ action: 'appendChild',
977
+ selector: 'main',
978
+ },
979
+ }),
980
+ },
981
+ ];
982
+
983
+ const patches = mapper.suggestionsToPatches('/page', suggestions, 'opp-faq-123', null);
984
+
985
+ expect(patches).to.be.an('array');
986
+ expect(patches.length).to.equal(2); // heading + FAQ
987
+ });
988
+
989
+ it('should handle markdown to HAST conversion errors in suggestionsToPatches', () => {
990
+ let errorCount = 0;
991
+ const errorLog = {
992
+ debug: () => {},
993
+ info: () => {},
994
+ warn: () => {},
995
+ error: () => { errorCount += 1; },
996
+ };
997
+
998
+ const errorMapper = new FaqMapper(errorLog);
999
+
1000
+ // Override buildFaqItemHast to throw an error
1001
+ const originalBuildFaqItemHast = errorMapper.buildFaqItemHast;
1002
+ errorMapper.buildFaqItemHast = () => {
1003
+ throw new Error('Markdown parsing failed');
1004
+ };
1005
+
1006
+ const suggestions = [
1007
+ {
1008
+ getId: () => 'sugg-faq-error',
1009
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1010
+ getData: () => ({
1011
+ url: 'https://www.example.com/page',
1012
+ headingText: 'FAQs',
1013
+ item: {
1014
+ question: 'Question?',
1015
+ answer: 'Answer.',
1016
+ },
1017
+ transformRules: {
1018
+ action: 'appendChild',
1019
+ selector: 'main',
1020
+ },
1021
+ }),
1022
+ },
1023
+ ];
1024
+
1025
+ const patches = errorMapper.suggestionsToPatches('/page', suggestions, 'opp-faq-error', null);
1026
+
1027
+ expect(patches).to.be.an('array');
1028
+ expect(patches.length).to.equal(1); // Only heading, FAQ item fails
1029
+ expect(patches[0].value.tagName).to.equal('h2'); // Heading patch
1030
+ expect(errorCount).to.be.greaterThan(0);
1031
+
1032
+ // Restore original method
1033
+ errorMapper.buildFaqItemHast = originalBuildFaqItemHast;
1034
+ });
1035
+
1036
+ it('should handle existing config with urlOptimizations but no patches', () => {
1037
+ const suggestion = {
1038
+ getId: () => 'sugg-faq-1',
1039
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1040
+ getData: () => ({
1041
+ url: 'https://www.example.com/page',
1042
+ headingText: 'FAQs',
1043
+ item: {
1044
+ question: 'Question?',
1045
+ answer: 'Answer.',
1046
+ },
1047
+ transformRules: {
1048
+ action: 'appendChild',
1049
+ selector: 'main',
1050
+ },
1051
+ }),
1052
+ };
1053
+
1054
+ // Config with urlOptimizations but no patches array
1055
+ const existingConfig = {
1056
+ tokowakaOptimizations: {
1057
+ '/page': {
1058
+ prerender: true,
1059
+ // No patches array
1060
+ },
1061
+ },
1062
+ };
1063
+
1064
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-faq-123', existingConfig);
1065
+
1066
+ expect(patches).to.be.an('array');
1067
+ expect(patches.length).to.equal(2); // heading + FAQ (both created)
1068
+ expect(patches[0].value.tagName).to.equal('h2'); // Heading
1069
+ expect(patches[1].value.tagName).to.equal('div'); // FAQ
1070
+ });
1071
+ });
1072
+
1073
+ describe('canDeploy - new format', () => {
1074
+ it('should accept new format with item.question and item.answer', () => {
1075
+ const suggestion = {
1076
+ getData: () => ({
1077
+ item: {
1078
+ question: 'Is this the new format?',
1079
+ answer: 'Yes, it is.',
1080
+ },
1081
+ url: 'https://www.example.com/page',
1082
+ transformRules: {
1083
+ action: 'appendChild',
1084
+ selector: 'main',
1085
+ },
1086
+ }),
1087
+ };
1088
+
1089
+ const result = mapper.canDeploy(suggestion);
1090
+ expect(result).to.deep.equal({ eligible: true });
1091
+ });
1092
+
1093
+ it('should reject when item.question/answer is missing', () => {
1094
+ const suggestion = {
1095
+ getData: () => ({
1096
+ transformRules: {
1097
+ action: 'appendChild',
1098
+ selector: 'main',
1099
+ },
1100
+ }),
1101
+ };
1102
+
1103
+ const result = mapper.canDeploy(suggestion);
1104
+ expect(result.eligible).to.be.false;
1105
+ expect(result.reason).to.include('item.question and item.answer are required');
1106
+ });
1107
+ });
1108
+
1109
+ describe('tokowakaDeployed filtering', () => {
1110
+ it('should skip heading when FAQ already deployed for URL', () => {
1111
+ const newSuggestion = {
1112
+ getId: () => 'sugg-new-1',
1113
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1114
+ getData: () => ({
1115
+ url: 'https://www.example.com/page',
1116
+ headingText: 'FAQs',
1117
+ item: {
1118
+ question: 'New question?',
1119
+ answer: 'New answer.',
1120
+ },
1121
+ transformRules: {
1122
+ action: 'appendChild',
1123
+ selector: 'main',
1124
+ },
1125
+ }),
1126
+ };
1127
+
1128
+ // Mock existingConfig with heading already present
1129
+ const existingConfig = {
1130
+ tokowakaOptimizations: {
1131
+ '/page': {
1132
+ patches: [
1133
+ {
1134
+ opportunityId: 'opp-faq-123',
1135
+ // No suggestionId = heading patch
1136
+ op: 'appendChild',
1137
+ selector: 'main',
1138
+ value: { type: 'element', tagName: 'h2', children: [{ type: 'text', value: 'FAQs' }] },
1139
+ },
1140
+ ],
1141
+ },
1142
+ },
1143
+ };
1144
+
1145
+ const patches = mapper.suggestionsToPatches(
1146
+ '/page',
1147
+ [newSuggestion],
1148
+ 'opp-faq-123',
1149
+ existingConfig,
1150
+ );
1151
+
1152
+ expect(patches).to.be.an('array');
1153
+ expect(patches.length).to.equal(1); // Only FAQ, no heading
1154
+ expect(patches[0].suggestionId).to.equal('sugg-new-1');
1155
+ expect(patches[0].value.tagName).to.equal('div'); // FAQ div
1156
+ expect(patches[0].selector).to.equal('main');
1157
+ expect(patches[0].op).to.equal('appendChild');
1158
+ });
1159
+
1160
+ it('should create heading when no patches exist for URL', () => {
1161
+ const newSuggestion = {
1162
+ getId: () => 'sugg-new-1',
1163
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1164
+ getData: () => ({
1165
+ url: 'https://www.example.com/page',
1166
+ headingText: 'FAQs',
1167
+ item: {
1168
+ question: 'New question?',
1169
+ answer: 'New answer.',
1170
+ },
1171
+ transformRules: {
1172
+ action: 'appendChild',
1173
+ selector: 'main',
1174
+ },
1175
+ }),
1176
+ };
1177
+
1178
+ // Empty config - no patches
1179
+ const existingConfig = null;
1180
+
1181
+ const patches = mapper.suggestionsToPatches(
1182
+ '/page',
1183
+ [newSuggestion],
1184
+ 'opp-faq-123',
1185
+ existingConfig,
1186
+ );
1187
+
1188
+ expect(patches).to.be.an('array');
1189
+ expect(patches.length).to.equal(2); // heading + FAQ
1190
+
1191
+ // First should be heading
1192
+ expect(patches[0].suggestionId).to.be.undefined;
1193
+ expect(patches[0].value.tagName).to.equal('h2');
1194
+
1195
+ // Second should be FAQ
1196
+ expect(patches[1].suggestionId).to.equal('sugg-new-1');
1197
+ expect(patches[1].value.tagName).to.equal('div');
1198
+ expect(patches[1].selector).to.equal('main');
1199
+ });
1200
+
1201
+ it('should work without existing config parameter', () => {
1202
+ const newSuggestion = {
1203
+ getId: () => 'sugg-new-1',
1204
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1205
+ getData: () => ({
1206
+ url: 'https://www.example.com/page',
1207
+ headingText: 'FAQs',
1208
+ item: {
1209
+ question: 'New question?',
1210
+ answer: 'New answer.',
1211
+ },
1212
+ transformRules: {
1213
+ action: 'appendChild',
1214
+ selector: 'main',
1215
+ },
1216
+ }),
1217
+ };
1218
+
1219
+ const patches = mapper.suggestionsToPatches(
1220
+ '/page',
1221
+ [newSuggestion],
1222
+ 'opp-faq-123',
1223
+ null, // No existing config
1224
+ );
1225
+
1226
+ expect(patches).to.be.an('array');
1227
+ expect(patches.length).to.equal(2); // heading + FAQ
1228
+ expect(patches[0].suggestionId).to.be.undefined; // heading
1229
+ expect(patches[1].suggestionId).to.equal('sugg-new-1'); // FAQ
1230
+ });
1231
+
1232
+ it('should handle invalid existing config', () => {
1233
+ const newSuggestion = {
1234
+ getId: () => 'sugg-new-1',
1235
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1236
+ getData: () => ({
1237
+ url: 'https://www.example.com/page',
1238
+ headingText: 'FAQs',
1239
+ item: {
1240
+ question: 'New question?',
1241
+ answer: 'New answer.',
1242
+ },
1243
+ transformRules: {
1244
+ action: 'appendChild',
1245
+ selector: 'main',
1246
+ },
1247
+ }),
1248
+ };
1249
+
1250
+ // Pass invalid config
1251
+ const patches = mapper.suggestionsToPatches(
1252
+ '/page',
1253
+ [newSuggestion],
1254
+ 'opp-faq-123',
1255
+ 'not-valid-config',
1256
+ );
1257
+
1258
+ expect(patches).to.be.an('array');
1259
+ expect(patches.length).to.equal(2); // heading + FAQ
1260
+ expect(patches[0].suggestionId).to.be.undefined; // heading
1261
+ expect(patches[1].suggestionId).to.equal('sugg-new-1'); // FAQ
1262
+ });
1263
+ });
1264
+ });