@adobe/spacecat-shared-tokowaka-client 1.1.1 → 1.2.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.
@@ -50,7 +50,12 @@ describe('TokowakaClient', () => {
50
50
  };
51
51
 
52
52
  client = new TokowakaClient(
53
- { bucketName: 'test-bucket', s3Client, env },
53
+ {
54
+ bucketName: 'test-bucket',
55
+ previewBucketName: 'test-preview-bucket',
56
+ s3Client,
57
+ env,
58
+ },
54
59
  log,
55
60
  );
56
61
 
@@ -58,7 +63,10 @@ describe('TokowakaClient', () => {
58
63
  getId: () => 'site-123',
59
64
  getBaseURL: () => 'https://example.com',
60
65
  getConfig: () => ({
61
- getTokowakaConfig: () => ({ apiKey: 'test-api-key-123' }),
66
+ getTokowakaConfig: () => ({
67
+ forwardedHost: 'example.com',
68
+ apiKey: 'test-api-key',
69
+ }),
62
70
  }),
63
71
  };
64
72
 
@@ -104,7 +112,8 @@ describe('TokowakaClient', () => {
104
112
  describe('constructor', () => {
105
113
  it('should create an instance with valid config', () => {
106
114
  expect(client).to.be.instanceOf(TokowakaClient);
107
- expect(client.bucketName).to.equal('test-bucket');
115
+ expect(client.deployBucketName).to.equal('test-bucket');
116
+ expect(client.previewBucketName).to.equal('test-preview-bucket');
108
117
  expect(client.s3Client).to.equal(s3Client);
109
118
  });
110
119
 
@@ -117,12 +126,24 @@ describe('TokowakaClient', () => {
117
126
  expect(() => new TokowakaClient({ bucketName: 'test-bucket' }, log))
118
127
  .to.throw('S3 client is required');
119
128
  });
129
+
130
+ it('should use deployBucketName for preview if previewBucketName not provided', () => {
131
+ const clientWithoutPreview = new TokowakaClient(
132
+ { bucketName: 'test-bucket', s3Client },
133
+ log,
134
+ );
135
+ // previewBucketName is undefined if not explicitly provided
136
+ expect(clientWithoutPreview.previewBucketName).to.be.undefined;
137
+ });
120
138
  });
121
139
 
122
140
  describe('createFrom', () => {
123
141
  it('should create client from context', () => {
124
142
  const context = {
125
- env: { TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket' },
143
+ env: {
144
+ TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket',
145
+ TOKOWAKA_PREVIEW_BUCKET: 'test-preview-bucket',
146
+ },
126
147
  s3: { s3Client },
127
148
  log,
128
149
  };
@@ -131,6 +152,7 @@ describe('TokowakaClient', () => {
131
152
 
132
153
  expect(createdClient).to.be.instanceOf(TokowakaClient);
133
154
  expect(context.tokowakaClient).to.equal(createdClient);
155
+ expect(createdClient.previewBucketName).to.equal('test-preview-bucket');
134
156
  });
135
157
 
136
158
  it('should reuse existing client from context', () => {
@@ -174,13 +196,8 @@ describe('TokowakaClient', () => {
174
196
  }
175
197
 
176
198
  // eslint-disable-next-line class-methods-use-this
177
- suggestionToPatch() {
178
- return {};
179
- }
180
-
181
- // eslint-disable-next-line class-methods-use-this
182
- validateSuggestionData() {
183
- return true;
199
+ suggestionsToPatches() {
200
+ return [];
184
201
  }
185
202
 
186
203
  // eslint-disable-next-line class-methods-use-this
@@ -198,214 +215,335 @@ describe('TokowakaClient', () => {
198
215
  });
199
216
 
200
217
  describe('generateConfig', () => {
201
- it('should generate config for headings opportunity', () => {
202
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
218
+ it('should generate config for headings opportunity with single URL', () => {
219
+ const url = 'https://example.com/page1';
220
+ const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
203
221
 
204
222
  expect(config).to.deep.include({
205
- siteId: 'site-123',
206
- baseURL: 'https://example.com',
223
+ url: 'https://example.com/page1',
207
224
  version: '1.0',
208
- tokowakaForceFail: false,
225
+ forceFail: false,
226
+ prerender: true,
209
227
  });
210
228
 
211
- expect(config.tokowakaOptimizations).to.have.property('/page1');
212
- expect(config.tokowakaOptimizations['/page1'].prerender).to.be.true;
213
- expect(config.tokowakaOptimizations['/page1'].patches).to.have.length(2);
229
+ expect(config.patches).to.have.length(2);
214
230
 
215
- const patch = config.tokowakaOptimizations['/page1'].patches[0];
231
+ const patch = config.patches[0];
216
232
  expect(patch).to.include({
217
233
  op: 'replace',
218
234
  selector: 'h1',
219
235
  value: 'New Heading',
220
236
  opportunityId: 'opp-123',
221
- suggestionId: 'sugg-1',
222
237
  prerenderRequired: true,
223
238
  });
239
+ expect(patch.suggestionId).to.equal('sugg-1');
224
240
  expect(patch).to.have.property('lastUpdated');
225
241
  });
226
242
 
227
- it('should group suggestions by URL path', () => {
243
+ it('should generate config for FAQ opportunity', () => {
244
+ mockOpportunity = {
245
+ getId: () => 'opp-faq-123',
246
+ getType: () => 'faq',
247
+ };
248
+
228
249
  mockSuggestions = [
229
250
  {
230
- getId: () => 'sugg-1',
251
+ getId: () => 'sugg-faq-1',
231
252
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
232
253
  getData: () => ({
233
254
  url: 'https://example.com/page1',
234
- recommendedAction: 'Page 1 Heading',
235
- checkType: 'heading-empty',
255
+ headingText: 'FAQs',
256
+ shouldOptimize: true,
257
+ item: {
258
+ question: 'Question 1?',
259
+ answer: 'Answer 1.',
260
+ },
236
261
  transformRules: {
237
- action: 'replace',
238
- selector: 'h1',
262
+ action: 'appendChild',
263
+ selector: 'main',
239
264
  },
240
265
  }),
241
266
  },
242
267
  {
243
- getId: () => 'sugg-2',
244
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
268
+ getId: () => 'sugg-faq-2',
269
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
245
270
  getData: () => ({
246
- url: 'https://example.com/page2',
247
- recommendedAction: 'Page 2 Heading',
248
- checkType: 'heading-empty',
271
+ url: 'https://example.com/page1',
272
+ headingText: 'FAQs',
273
+ shouldOptimize: true,
274
+ item: {
275
+ question: 'Question 2?',
276
+ answer: 'Answer 2.',
277
+ },
249
278
  transformRules: {
250
- action: 'replace',
251
- selector: 'h1',
279
+ action: 'appendChild',
280
+ selector: 'main',
252
281
  },
253
282
  }),
254
283
  },
255
284
  ];
256
285
 
257
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
286
+ const url = 'https://example.com/page1';
287
+ const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
258
288
 
259
- expect(Object.keys(config.tokowakaOptimizations)).to.have.length(2);
260
- expect(config.tokowakaOptimizations).to.have.property('/page1');
261
- expect(config.tokowakaOptimizations).to.have.property('/page2');
262
- });
289
+ expect(config).to.deep.include({
290
+ url: 'https://example.com/page1',
291
+ version: '1.0',
292
+ forceFail: false,
293
+ prerender: true,
294
+ });
263
295
 
264
- it('should use overrideBaseURL from fetchConfig when available', () => {
265
- // Set up mockSite with overrideBaseURL
266
- mockSite.getConfig = () => ({
267
- getTokowakaConfig: () => ({
268
- apiKey: 'test-api-key-123',
269
- cdnProvider: 'cloudfront',
270
- }),
271
- getFetchConfig: () => ({
272
- overrideBaseURL: 'https://override.example.com',
273
- }),
296
+ expect(config.patches).to.have.length(3); // heading + 2 FAQs
297
+
298
+ // First patch: heading (no suggestionId)
299
+ const headingPatch = config.patches[0];
300
+ expect(headingPatch).to.include({
301
+ op: 'appendChild',
302
+ selector: 'main',
303
+ opportunityId: 'opp-faq-123',
304
+ prerenderRequired: true,
274
305
  });
306
+ expect(headingPatch.suggestionId).to.be.undefined;
307
+ expect(headingPatch).to.have.property('lastUpdated');
308
+ expect(headingPatch.value.tagName).to.equal('h2');
309
+
310
+ // Second patch: first FAQ
311
+ const firstFaqPatch = config.patches[1];
312
+ expect(firstFaqPatch).to.include({
313
+ op: 'appendChild',
314
+ selector: 'main',
315
+ opportunityId: 'opp-faq-123',
316
+ prerenderRequired: true,
317
+ });
318
+ expect(firstFaqPatch.suggestionId).to.equal('sugg-faq-1');
319
+ expect(firstFaqPatch.value.tagName).to.equal('div');
320
+ });
275
321
 
322
+ it('should return null if no eligible suggestions', () => {
276
323
  mockSuggestions = [
277
324
  {
278
- getId: () => 'sugg-override',
279
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
325
+ getId: () => 'sugg-1',
280
326
  getData: () => ({
281
- url: '/relative-path',
282
- recommendedAction: 'Heading',
283
- checkType: 'heading-empty',
284
- transformRules: {
285
- action: 'replace',
286
- selector: 'h1',
287
- },
327
+ url: 'https://example.com/page1',
328
+ // Missing required fields
288
329
  }),
289
330
  },
290
331
  ];
291
332
 
292
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
333
+ const url = 'https://example.com/page1';
334
+ const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
293
335
 
294
- expect(Object.keys(config.tokowakaOptimizations)).to.have.length(1);
295
- expect(config.tokowakaOptimizations).to.have.property('/relative-path');
336
+ expect(config).to.be.null;
296
337
  });
297
338
 
298
- it('should skip suggestions without URL', () => {
299
- mockSuggestions = [
300
- {
301
- getId: () => 'sugg-1',
302
- getData: () => ({
303
- selector: 'h1',
304
- value: 'Heading without URL',
305
- }),
339
+ it('should handle unsupported opportunity types', () => {
340
+ mockOpportunity.getType = () => 'unsupported-type';
341
+
342
+ expect(() => client.generateConfig('https://example.com/page1', mockOpportunity, mockSuggestions))
343
+ .to.throw(/No mapper found for opportunity type: unsupported-type/)
344
+ .with.property('status', 501);
345
+ });
346
+ });
347
+
348
+ describe('fetchMetaconfig', () => {
349
+ it('should fetch metaconfig from S3', async () => {
350
+ const metaconfig = {
351
+ siteId: 'site-123',
352
+ prerender: true,
353
+ };
354
+
355
+ s3Client.send.resolves({
356
+ Body: {
357
+ transformToString: async () => JSON.stringify(metaconfig),
306
358
  },
307
- ];
359
+ });
360
+
361
+ const result = await client.fetchMetaconfig('https://example.com/page1');
308
362
 
309
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
363
+ expect(result).to.deep.equal(metaconfig);
364
+ expect(s3Client.send).to.have.been.calledOnce;
310
365
 
311
- expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
312
- expect(log.warn).to.have.been.calledWith(sinon.match(/does not have a URL/));
366
+ const command = s3Client.send.firstCall.args[0];
367
+ expect(command.input.Bucket).to.equal('test-bucket');
368
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
313
369
  });
314
370
 
315
- it('should skip suggestions with invalid URL', () => {
316
- mockSuggestions = [
317
- {
318
- getId: () => 'sugg-1',
319
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
320
- getData: () => ({
321
- url: 'http://invalid domain with spaces.com',
322
- checkType: 'heading-empty',
323
- recommendedAction: 'Heading',
324
- transformRules: {
325
- action: 'replace',
326
- selector: 'h1',
327
- },
328
- }),
371
+ it('should fetch metaconfig from preview bucket', async () => {
372
+ const metaconfig = {
373
+ siteId: 'site-123',
374
+ prerender: true,
375
+ };
376
+
377
+ s3Client.send.resolves({
378
+ Body: {
379
+ transformToString: async () => JSON.stringify(metaconfig),
329
380
  },
330
- ];
381
+ });
331
382
 
332
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
383
+ await client.fetchMetaconfig('https://example.com/page1', true);
333
384
 
334
- expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
335
- expect(log.warn).to.have.been.calledWith(sinon.match(/Failed to extract pathname from URL/));
385
+ const command = s3Client.send.firstCall.args[0];
386
+ expect(command.input.Bucket).to.equal('test-preview-bucket');
387
+ expect(command.input.Key).to.equal('preview/opportunities/example.com/config');
336
388
  });
337
389
 
338
- it('should skip suggestions with missing required fields', () => {
339
- mockSuggestions = [
340
- {
341
- getId: () => 'sugg-1',
342
- getData: () => ({
343
- url: 'https://example.com/page1',
344
- // Missing required fields
345
- }),
346
- },
347
- ];
390
+ it('should return null if metaconfig does not exist', async () => {
391
+ const noSuchKeyError = new Error('NoSuchKey');
392
+ noSuchKeyError.name = 'NoSuchKey';
393
+ s3Client.send.rejects(noSuchKeyError);
348
394
 
349
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
395
+ const result = await client.fetchMetaconfig('https://example.com/page1');
350
396
 
351
- expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
352
- expect(log.warn).to.have.been.calledWith(sinon.match(/cannot be deployed/));
397
+ expect(result).to.be.null;
353
398
  });
354
399
 
355
- it('should handle unsupported opportunity types', () => {
356
- mockOpportunity.getType = () => 'unsupported-type';
357
- mockSuggestions = [
358
- {
359
- getId: () => 'sugg-1',
360
- getData: () => ({
361
- url: 'https://example.com/page1',
362
- }),
363
- },
364
- ];
400
+ it('should throw error on S3 fetch failure', async () => {
401
+ const s3Error = new Error('Access Denied');
402
+ s3Error.name = 'AccessDenied';
403
+ s3Client.send.rejects(s3Error);
365
404
 
366
- expect(() => client.generateConfig(mockSite, mockOpportunity, mockSuggestions))
367
- .to.throw(/No mapper found for opportunity type: unsupported-type/)
368
- .with.property('status', 501);
405
+ try {
406
+ await client.fetchMetaconfig('https://example.com/page1');
407
+ expect.fail('Should have thrown error');
408
+ } catch (error) {
409
+ expect(error.message).to.include('S3 fetch failed');
410
+ expect(error.status).to.equal(500);
411
+ }
412
+ });
413
+
414
+ it('should throw error if URL is missing', async () => {
415
+ try {
416
+ await client.fetchMetaconfig('');
417
+ expect.fail('Should have thrown error');
418
+ } catch (error) {
419
+ expect(error.message).to.include('URL is required');
420
+ expect(error.status).to.equal(400);
421
+ }
422
+ });
423
+ });
424
+
425
+ describe('uploadMetaconfig', () => {
426
+ it('should upload metaconfig to S3', async () => {
427
+ const metaconfig = {
428
+ siteId: 'site-123',
429
+ prerender: true,
430
+ };
431
+
432
+ const s3Path = await client.uploadMetaconfig('https://example.com/page1', metaconfig);
433
+
434
+ expect(s3Path).to.equal('opportunities/example.com/config');
435
+ expect(s3Client.send).to.have.been.calledOnce;
436
+
437
+ const command = s3Client.send.firstCall.args[0];
438
+ expect(command.input.Bucket).to.equal('test-bucket');
439
+ expect(command.input.Key).to.equal('opportunities/example.com/config');
440
+ expect(command.input.ContentType).to.equal('application/json');
441
+ expect(JSON.parse(command.input.Body)).to.deep.equal(metaconfig);
442
+ });
443
+
444
+ it('should upload metaconfig to preview bucket', async () => {
445
+ const metaconfig = {
446
+ siteId: 'site-123',
447
+ prerender: true,
448
+ };
449
+
450
+ const s3Path = await client.uploadMetaconfig('https://example.com/page1', metaconfig, true);
451
+
452
+ expect(s3Path).to.equal('preview/opportunities/example.com/config');
453
+
454
+ const command = s3Client.send.firstCall.args[0];
455
+ expect(command.input.Bucket).to.equal('test-preview-bucket');
456
+ });
457
+
458
+ it('should throw error if URL is missing', async () => {
459
+ try {
460
+ await client.uploadMetaconfig('', { siteId: 'site-123', prerender: true });
461
+ expect.fail('Should have thrown error');
462
+ } catch (error) {
463
+ expect(error.message).to.include('URL is required');
464
+ expect(error.status).to.equal(400);
465
+ }
466
+ });
467
+
468
+ it('should throw error if metaconfig is empty', async () => {
469
+ try {
470
+ await client.uploadMetaconfig('https://example.com/page1', {});
471
+ expect.fail('Should have thrown error');
472
+ } catch (error) {
473
+ expect(error.message).to.include('Metaconfig object is required');
474
+ expect(error.status).to.equal(400);
475
+ }
476
+ });
477
+
478
+ it('should throw error on S3 upload failure', async () => {
479
+ const s3Error = new Error('Access Denied');
480
+ s3Client.send.rejects(s3Error);
481
+
482
+ try {
483
+ await client.uploadMetaconfig('https://example.com/page1', { siteId: 'site-123', prerender: true });
484
+ expect.fail('Should have thrown error');
485
+ } catch (error) {
486
+ expect(error.message).to.include('S3 upload failed');
487
+ expect(error.status).to.equal(500);
488
+ }
369
489
  });
370
490
  });
371
491
 
372
492
  describe('uploadConfig', () => {
373
493
  it('should upload config to S3', async () => {
374
494
  const config = {
375
- siteId: 'site-123',
376
- baseURL: 'https://example.com',
495
+ url: 'https://example.com/page1',
377
496
  version: '1.0',
378
- tokowakaForceFail: false,
379
- tokowakaOptimizations: {},
497
+ forceFail: false,
498
+ prerender: true,
499
+ patches: [],
380
500
  };
381
501
 
382
- const s3Key = await client.uploadConfig('test-api-key', config);
502
+ const s3Key = await client.uploadConfig('https://example.com/page1', config);
383
503
 
384
- expect(s3Key).to.equal('opportunities/test-api-key');
504
+ expect(s3Key).to.equal('opportunities/example.com/L3BhZ2Ux');
385
505
  expect(s3Client.send).to.have.been.calledOnce;
386
506
 
387
507
  const command = s3Client.send.firstCall.args[0];
388
508
  expect(command.input.Bucket).to.equal('test-bucket');
389
- expect(command.input.Key).to.equal('opportunities/test-api-key');
509
+ expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
390
510
  expect(command.input.ContentType).to.equal('application/json');
391
511
  expect(JSON.parse(command.input.Body)).to.deep.equal(config);
392
512
  });
393
513
 
394
- it('should throw error if apiKey is missing', async () => {
395
- const config = { siteId: 'site-123' };
514
+ it('should upload config to preview bucket', async () => {
515
+ const config = {
516
+ url: 'https://example.com/page1',
517
+ version: '1.0',
518
+ forceFail: false,
519
+ prerender: true,
520
+ patches: [],
521
+ };
522
+
523
+ const s3Key = await client.uploadConfig('https://example.com/page1', config, true);
524
+
525
+ expect(s3Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
526
+
527
+ const command = s3Client.send.firstCall.args[0];
528
+ expect(command.input.Bucket).to.equal('test-preview-bucket');
529
+ expect(command.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
530
+ });
531
+
532
+ it('should throw error if URL is missing', async () => {
533
+ const config = { url: 'https://example.com/page1', patches: [] };
396
534
 
397
535
  try {
398
536
  await client.uploadConfig('', config);
399
537
  expect.fail('Should have thrown error');
400
538
  } catch (error) {
401
- expect(error.message).to.include('Tokowaka API key is required');
539
+ expect(error.message).to.include('URL is required');
402
540
  expect(error.status).to.equal(400);
403
541
  }
404
542
  });
405
543
 
406
544
  it('should throw error if config is empty', async () => {
407
545
  try {
408
- await client.uploadConfig('test-api-key', {});
546
+ await client.uploadConfig('https://example.com/page1', {});
409
547
  expect.fail('Should have thrown error');
410
548
  } catch (error) {
411
549
  expect(error.message).to.include('Config object is required');
@@ -415,10 +553,10 @@ describe('TokowakaClient', () => {
415
553
 
416
554
  it('should handle S3 upload failure', async () => {
417
555
  s3Client.send.rejects(new Error('Network error'));
418
- const config = { siteId: 'site-123', tokowakaOptimizations: {} };
556
+ const config = { url: 'https://example.com/page1', patches: [] };
419
557
 
420
558
  try {
421
- await client.uploadConfig('test-api-key', config);
559
+ await client.uploadConfig('https://example.com/page1', config);
422
560
  expect.fail('Should have thrown error');
423
561
  } catch (error) {
424
562
  expect(error.message).to.include('S3 upload failed');
@@ -430,26 +568,21 @@ describe('TokowakaClient', () => {
430
568
  describe('fetchConfig', () => {
431
569
  it('should fetch existing config from S3', async () => {
432
570
  const existingConfig = {
433
- siteId: 'site-123',
434
- baseURL: 'https://example.com',
571
+ url: 'https://example.com/page1',
435
572
  version: '1.0',
436
- tokowakaForceFail: false,
437
- tokowakaOptimizations: {
438
- '/page1': {
439
- prerender: true,
440
- patches: [
441
- {
442
- op: 'replace',
443
- selector: 'h1',
444
- value: 'Old Heading',
445
- opportunityId: 'opp-123',
446
- suggestionId: 'sugg-1',
447
- prerenderRequired: true,
448
- lastUpdated: 1234567890,
449
- },
450
- ],
573
+ forceFail: false,
574
+ prerender: true,
575
+ patches: [
576
+ {
577
+ op: 'replace',
578
+ selector: 'h1',
579
+ value: 'Old Heading',
580
+ opportunityId: 'opp-123',
581
+ suggestionId: 'sugg-1',
582
+ prerenderRequired: true,
583
+ lastUpdated: 1234567890,
451
584
  },
452
- },
585
+ ],
453
586
  };
454
587
 
455
588
  s3Client.send.resolves({
@@ -458,14 +591,36 @@ describe('TokowakaClient', () => {
458
591
  },
459
592
  });
460
593
 
461
- const config = await client.fetchConfig('test-api-key');
594
+ const config = await client.fetchConfig('https://example.com/page1');
462
595
 
463
596
  expect(config).to.deep.equal(existingConfig);
464
597
  expect(s3Client.send).to.have.been.calledOnce;
465
598
 
466
599
  const command = s3Client.send.firstCall.args[0];
467
600
  expect(command.input.Bucket).to.equal('test-bucket');
468
- expect(command.input.Key).to.equal('opportunities/test-api-key');
601
+ expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
602
+ });
603
+
604
+ it('should fetch config from preview bucket', async () => {
605
+ const existingConfig = {
606
+ url: 'https://example.com/page1',
607
+ version: '1.0',
608
+ forceFail: false,
609
+ prerender: true,
610
+ patches: [],
611
+ };
612
+
613
+ s3Client.send.resolves({
614
+ Body: {
615
+ transformToString: async () => JSON.stringify(existingConfig),
616
+ },
617
+ });
618
+
619
+ await client.fetchConfig('https://example.com/page1', true);
620
+
621
+ const command = s3Client.send.firstCall.args[0];
622
+ expect(command.input.Bucket).to.equal('test-preview-bucket');
623
+ expect(command.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
469
624
  });
470
625
 
471
626
  it('should return null if config does not exist', async () => {
@@ -473,7 +628,7 @@ describe('TokowakaClient', () => {
473
628
  noSuchKeyError.name = 'NoSuchKey';
474
629
  s3Client.send.rejects(noSuchKeyError);
475
630
 
476
- const config = await client.fetchConfig('test-api-key');
631
+ const config = await client.fetchConfig('https://example.com/page1');
477
632
 
478
633
  expect(config).to.be.null;
479
634
  });
@@ -483,17 +638,17 @@ describe('TokowakaClient', () => {
483
638
  noSuchKeyError.Code = 'NoSuchKey';
484
639
  s3Client.send.rejects(noSuchKeyError);
485
640
 
486
- const config = await client.fetchConfig('test-api-key');
641
+ const config = await client.fetchConfig('https://example.com/page1');
487
642
 
488
643
  expect(config).to.be.null;
489
644
  });
490
645
 
491
- it('should throw error if apiKey is missing', async () => {
646
+ it('should throw error if URL is missing', async () => {
492
647
  try {
493
648
  await client.fetchConfig('');
494
649
  expect.fail('Should have thrown error');
495
650
  } catch (error) {
496
- expect(error.message).to.include('Tokowaka API key is required');
651
+ expect(error.message).to.include('URL is required');
497
652
  expect(error.status).to.equal(400);
498
653
  }
499
654
  });
@@ -502,7 +657,7 @@ describe('TokowakaClient', () => {
502
657
  s3Client.send.rejects(new Error('Network error'));
503
658
 
504
659
  try {
505
- await client.fetchConfig('test-api-key');
660
+ await client.fetchConfig('https://example.com/page1');
506
661
  expect.fail('Should have thrown error');
507
662
  } catch (error) {
508
663
  expect(error.message).to.include('S3 fetch failed');
@@ -517,58 +672,48 @@ describe('TokowakaClient', () => {
517
672
 
518
673
  beforeEach(() => {
519
674
  existingConfig = {
520
- siteId: 'site-123',
521
- baseURL: 'https://example.com',
675
+ url: 'https://example.com/page1',
522
676
  version: '1.0',
523
- tokowakaForceFail: false,
524
- tokowakaOptimizations: {
525
- '/page1': {
526
- prerender: true,
527
- patches: [
528
- {
529
- op: 'replace',
530
- selector: 'h1',
531
- value: 'Old Heading',
532
- opportunityId: 'opp-123',
533
- suggestionId: 'sugg-1',
534
- prerenderRequired: true,
535
- lastUpdated: 1234567890,
536
- },
537
- {
538
- op: 'replace',
539
- selector: 'h2',
540
- value: 'Old Subtitle',
541
- opportunityId: 'opp-456',
542
- suggestionId: 'sugg-2',
543
- prerenderRequired: true,
544
- lastUpdated: 1234567890,
545
- },
546
- ],
677
+ forceFail: false,
678
+ prerender: true,
679
+ patches: [
680
+ {
681
+ op: 'replace',
682
+ selector: 'h1',
683
+ value: 'Old Heading',
684
+ opportunityId: 'opp-123',
685
+ suggestionId: 'sugg-1',
686
+ prerenderRequired: true,
687
+ lastUpdated: 1234567890,
547
688
  },
548
- },
689
+ {
690
+ op: 'replace',
691
+ selector: 'h2',
692
+ value: 'Old Subtitle',
693
+ opportunityId: 'opp-456',
694
+ suggestionId: 'sugg-2',
695
+ prerenderRequired: true,
696
+ lastUpdated: 1234567890,
697
+ },
698
+ ],
549
699
  };
550
700
 
551
701
  newConfig = {
552
- siteId: 'site-123',
553
- baseURL: 'https://example.com',
702
+ url: 'https://example.com/page1',
554
703
  version: '1.0',
555
- tokowakaForceFail: false,
556
- tokowakaOptimizations: {
557
- '/page1': {
558
- prerender: true,
559
- patches: [
560
- {
561
- op: 'replace',
562
- selector: 'h1',
563
- value: 'Updated Heading',
564
- opportunityId: 'opp-123',
565
- suggestionId: 'sugg-1',
566
- prerenderRequired: true,
567
- lastUpdated: 1234567900,
568
- },
569
- ],
704
+ forceFail: false,
705
+ prerender: true,
706
+ patches: [
707
+ {
708
+ op: 'replace',
709
+ selector: 'h1',
710
+ value: 'Updated Heading',
711
+ opportunityId: 'opp-123',
712
+ suggestionId: 'sugg-1',
713
+ prerenderRequired: true,
714
+ lastUpdated: 1234567900,
570
715
  },
571
- },
716
+ ],
572
717
  };
573
718
  });
574
719
 
@@ -581,21 +726,21 @@ describe('TokowakaClient', () => {
581
726
  it('should update existing patch with same opportunityId and suggestionId', () => {
582
727
  const merged = client.mergeConfigs(existingConfig, newConfig);
583
728
 
584
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
729
+ expect(merged.patches).to.have.length(2);
585
730
 
586
731
  // First patch should be updated
587
- const updatedPatch = merged.tokowakaOptimizations['/page1'].patches[0];
732
+ const updatedPatch = merged.patches[0];
588
733
  expect(updatedPatch.value).to.equal('Updated Heading');
589
734
  expect(updatedPatch.lastUpdated).to.equal(1234567900);
590
735
 
591
736
  // Second patch should remain unchanged
592
- const unchangedPatch = merged.tokowakaOptimizations['/page1'].patches[1];
737
+ const unchangedPatch = merged.patches[1];
593
738
  expect(unchangedPatch.value).to.equal('Old Subtitle');
594
739
  expect(unchangedPatch.opportunityId).to.equal('opp-456');
595
740
  });
596
741
 
597
742
  it('should add new patch if opportunityId and suggestionId do not exist', () => {
598
- newConfig.tokowakaOptimizations['/page1'].patches.push({
743
+ newConfig.patches.push({
599
744
  op: 'replace',
600
745
  selector: 'h3',
601
746
  value: 'New Section Title',
@@ -607,106 +752,59 @@ describe('TokowakaClient', () => {
607
752
 
608
753
  const merged = client.mergeConfigs(existingConfig, newConfig);
609
754
 
610
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(3);
755
+ expect(merged.patches).to.have.length(3);
611
756
 
612
757
  // New patch should be added at the end
613
- const newPatch = merged.tokowakaOptimizations['/page1'].patches[2];
758
+ const newPatch = merged.patches[2];
614
759
  expect(newPatch.value).to.equal('New Section Title');
615
760
  expect(newPatch.opportunityId).to.equal('opp-789');
616
761
  expect(newPatch.suggestionId).to.equal('sugg-3');
617
762
  });
618
763
 
619
- it('should add new URL path if it does not exist in existing config', () => {
620
- newConfig.tokowakaOptimizations['/page2'] = {
621
- prerender: true,
622
- patches: [
623
- {
624
- op: 'replace',
625
- selector: 'h1',
626
- value: 'Page 2 Heading',
627
- opportunityId: 'opp-999',
628
- suggestionId: 'sugg-4',
629
- prerenderRequired: true,
630
- lastUpdated: 1234567900,
631
- },
632
- ],
633
- };
634
-
635
- const merged = client.mergeConfigs(existingConfig, newConfig);
636
-
637
- expect(merged.tokowakaOptimizations).to.have.property('/page1');
638
- expect(merged.tokowakaOptimizations).to.have.property('/page2');
639
- expect(merged.tokowakaOptimizations['/page2'].patches).to.have.length(1);
640
- expect(merged.tokowakaOptimizations['/page2'].patches[0].value).to.equal('Page 2 Heading');
641
- });
642
-
643
- it('should preserve existing URL paths not present in new config', () => {
644
- existingConfig.tokowakaOptimizations['/page3'] = {
645
- prerender: false,
646
- patches: [
647
- {
648
- op: 'replace',
649
- selector: 'h1',
650
- value: 'Page 3 Heading',
651
- opportunityId: 'opp-333',
652
- suggestionId: 'sugg-5',
653
- prerenderRequired: false,
654
- lastUpdated: 1234567890,
655
- },
656
- ],
657
- };
658
-
659
- const merged = client.mergeConfigs(existingConfig, newConfig);
660
-
661
- expect(merged.tokowakaOptimizations).to.have.property('/page1');
662
- expect(merged.tokowakaOptimizations).to.have.property('/page3');
663
- expect(merged.tokowakaOptimizations['/page3'].patches[0].value).to.equal('Page 3 Heading');
664
- });
665
-
666
764
  it('should update config metadata from new config', () => {
667
765
  newConfig.version = '2.0';
668
- newConfig.tokowakaForceFail = true;
766
+ newConfig.forceFail = true;
669
767
 
670
768
  const merged = client.mergeConfigs(existingConfig, newConfig);
671
769
 
672
770
  expect(merged.version).to.equal('2.0');
673
- expect(merged.tokowakaForceFail).to.equal(true);
771
+ expect(merged.forceFail).to.equal(true);
674
772
  });
675
773
 
676
774
  it('should handle empty patches array in existing config', () => {
677
- existingConfig.tokowakaOptimizations['/page1'].patches = [];
775
+ existingConfig.patches = [];
678
776
 
679
777
  const merged = client.mergeConfigs(existingConfig, newConfig);
680
778
 
681
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(1);
682
- expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Updated Heading');
779
+ expect(merged.patches).to.have.length(1);
780
+ expect(merged.patches[0].value).to.equal('Updated Heading');
683
781
  });
684
782
 
685
783
  it('should handle empty patches array in new config', () => {
686
- newConfig.tokowakaOptimizations['/page1'].patches = [];
784
+ newConfig.patches = [];
687
785
 
688
786
  const merged = client.mergeConfigs(existingConfig, newConfig);
689
787
 
690
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
691
- expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Old Heading');
788
+ expect(merged.patches).to.have.length(2);
789
+ expect(merged.patches[0].value).to.equal('Old Heading');
692
790
  });
693
791
 
694
- it('should handle missing patches property in existing config', () => {
695
- delete existingConfig.tokowakaOptimizations['/page1'].patches;
792
+ it('should handle undefined patches in existing config', () => {
793
+ existingConfig.patches = undefined;
696
794
 
697
795
  const merged = client.mergeConfigs(existingConfig, newConfig);
698
796
 
699
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(1);
700
- expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Updated Heading');
797
+ expect(merged.patches).to.have.length(1);
798
+ expect(merged.patches[0].value).to.equal('Updated Heading');
701
799
  });
702
800
 
703
- it('should handle missing patches property in new config', () => {
704
- delete newConfig.tokowakaOptimizations['/page1'].patches;
801
+ it('should handle undefined patches in new config', () => {
802
+ newConfig.patches = undefined;
705
803
 
706
804
  const merged = client.mergeConfigs(existingConfig, newConfig);
707
805
 
708
- expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
709
- expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Old Heading');
806
+ expect(merged.patches).to.have.length(2);
807
+ expect(merged.patches[0].value).to.equal('Old Heading');
710
808
  });
711
809
  });
712
810
 
@@ -720,6 +818,10 @@ describe('TokowakaClient', () => {
720
818
  });
721
819
  // Stub fetchConfig to return null by default (no existing config)
722
820
  sinon.stub(client, 'fetchConfig').resolves(null);
821
+ // Stub fetchMetaconfig to return null by default (will create new)
822
+ sinon.stub(client, 'fetchMetaconfig').resolves(null);
823
+ // Stub uploadMetaconfig
824
+ sinon.stub(client, 'uploadMetaconfig').resolves('opportunities/example.com/config');
723
825
  });
724
826
 
725
827
  it('should deploy suggestions successfully', async () => {
@@ -729,33 +831,98 @@ describe('TokowakaClient', () => {
729
831
  mockSuggestions,
730
832
  );
731
833
 
732
- expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
733
- expect(s3Client.send).to.have.been.calledOnce;
834
+ expect(result).to.have.property('s3Paths');
835
+ expect(result.s3Paths).to.be.an('array').with.length(1);
836
+ expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
837
+ expect(result).to.have.property('cdnInvalidations');
838
+ expect(result.cdnInvalidations).to.be.an('array').with.length(1);
839
+ expect(result.succeededSuggestions).to.have.length(2);
840
+ expect(result.failedSuggestions).to.have.length(0);
841
+ expect(s3Client.send).to.have.been.called;
734
842
  });
735
843
 
736
- it('should throw error if site does not have Tokowaka API key', async () => {
737
- mockSite.getConfig = () => ({
738
- getTokowakaConfig: () => ({}),
844
+ it('should create metaconfig on first deployment', async () => {
845
+ await client.deploySuggestions(
846
+ mockSite,
847
+ mockOpportunity,
848
+ mockSuggestions,
849
+ );
850
+
851
+ expect(client.fetchMetaconfig).to.have.been.calledOnce;
852
+ expect(client.uploadMetaconfig).to.have.been.calledOnce;
853
+
854
+ const metaconfigArg = client.uploadMetaconfig.firstCall.args[1];
855
+ expect(metaconfigArg).to.deep.include({
856
+ siteId: 'site-123',
857
+ prerender: true,
739
858
  });
859
+ });
740
860
 
741
- try {
742
- await client.deploySuggestions(mockSite, mockOpportunity, mockSuggestions);
743
- expect.fail('Should have thrown error');
744
- } catch (error) {
745
- expect(error.message).to.include('Tokowaka API key configured');
746
- expect(error.status).to.equal(400);
747
- }
861
+ it('should reuse existing metaconfig', async () => {
862
+ client.fetchMetaconfig.resolves({
863
+ siteId: 'site-123',
864
+ prerender: true,
865
+ });
866
+
867
+ await client.deploySuggestions(
868
+ mockSite,
869
+ mockOpportunity,
870
+ mockSuggestions,
871
+ );
872
+
873
+ expect(client.fetchMetaconfig).to.have.been.calledOnce;
874
+ expect(client.uploadMetaconfig).to.not.have.been.called;
875
+ });
876
+
877
+ it('should handle suggestions for multiple URLs', async () => {
878
+ mockSuggestions = [
879
+ {
880
+ getId: () => 'sugg-1',
881
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
882
+ getData: () => ({
883
+ url: 'https://example.com/page1',
884
+ recommendedAction: 'Page 1 Heading',
885
+ checkType: 'heading-empty',
886
+ transformRules: {
887
+ action: 'replace',
888
+ selector: 'h1',
889
+ },
890
+ }),
891
+ },
892
+ {
893
+ getId: () => 'sugg-2',
894
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
895
+ getData: () => ({
896
+ url: 'https://example.com/page2',
897
+ recommendedAction: 'Page 2 Heading',
898
+ checkType: 'heading-empty',
899
+ transformRules: {
900
+ action: 'replace',
901
+ selector: 'h1',
902
+ },
903
+ }),
904
+ },
905
+ ];
906
+
907
+ const result = await client.deploySuggestions(
908
+ mockSite,
909
+ mockOpportunity,
910
+ mockSuggestions,
911
+ );
912
+
913
+ expect(result.s3Paths).to.have.length(2);
914
+ expect(result.cdnInvalidations).to.have.length(2);
915
+ expect(result.succeededSuggestions).to.have.length(2);
748
916
  });
749
917
 
750
918
  it('should handle suggestions that are not eligible for deployment', async () => {
751
- // Create suggestions with different checkTypes
752
919
  mockSuggestions = [
753
920
  {
754
921
  getId: () => 'sugg-1',
755
922
  getData: () => ({
756
923
  url: 'https://example.com/page1',
757
924
  recommendedAction: 'New Heading',
758
- checkType: 'heading-missing', // Not eligible (wrong checkType name)
925
+ checkType: 'heading-missing', // Not eligible
759
926
  }),
760
927
  },
761
928
  {
@@ -784,15 +951,27 @@ describe('TokowakaClient', () => {
784
951
  expect(result.failedSuggestions[0].reason).to.include('can be deployed');
785
952
  });
786
953
 
787
- it('should return early when no eligible suggestions', async () => {
788
- // All suggestions are ineligible
954
+ it('should handle multi-URL deploy where one URL has no eligible suggestions', async () => {
789
955
  mockSuggestions = [
790
956
  {
791
957
  getId: () => 'sugg-1',
958
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
792
959
  getData: () => ({
793
960
  url: 'https://example.com/page1',
794
961
  recommendedAction: 'New Heading',
795
- checkType: 'heading-missing', // Wrong checkType name, not eligible
962
+ checkType: 'heading-empty', // Eligible
963
+ transformRules: {
964
+ action: 'replace',
965
+ selector: 'h1',
966
+ },
967
+ }),
968
+ },
969
+ {
970
+ getId: () => 'sugg-2',
971
+ getData: () => ({
972
+ url: 'https://example.com/page2',
973
+ recommendedAction: 'New Heading',
974
+ checkType: 'heading-missing', // Not eligible
796
975
  }),
797
976
  },
798
977
  ];
@@ -803,20 +982,32 @@ describe('TokowakaClient', () => {
803
982
  mockSuggestions,
804
983
  );
805
984
 
806
- expect(result.succeededSuggestions).to.have.length(0);
985
+ expect(result.succeededSuggestions).to.have.length(1);
807
986
  expect(result.failedSuggestions).to.have.length(1);
808
- expect(log.warn).to.have.been.calledWith('No eligible suggestions to deploy');
809
- expect(s3Client.send).to.not.have.been.called;
987
+ expect(result.failedSuggestions[0].suggestion.getId()).to.equal('sugg-2');
810
988
  });
811
989
 
812
- it('should return early when suggestions pass eligibility but fail during config generation', async () => {
813
- // Suggestions pass canDeploy but have no URL (caught in generateConfig)
990
+ it('should skip URL when generateConfig returns no patches', async () => {
991
+ // Stub mapper to return empty patches for the first call, normal for subsequent calls
992
+ const mapper = client.mapperRegistry.getMapper('headings');
993
+ const originalSuggestionsToPatches = mapper.suggestionsToPatches.bind(mapper);
994
+ let callCount = 0;
995
+ sinon.stub(mapper, 'suggestionsToPatches').callsFake((...args) => {
996
+ callCount += 1;
997
+ if (callCount === 1) {
998
+ // First call (for page1) returns no patches
999
+ return [];
1000
+ }
1001
+ // Subsequent calls work normally
1002
+ return originalSuggestionsToPatches(...args);
1003
+ });
1004
+
814
1005
  mockSuggestions = [
815
1006
  {
816
1007
  getId: () => 'sugg-1',
817
1008
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
818
1009
  getData: () => ({
819
- // Missing URL
1010
+ url: 'https://example.com/page1',
820
1011
  recommendedAction: 'New Heading',
821
1012
  checkType: 'heading-empty',
822
1013
  transformRules: {
@@ -825,6 +1016,42 @@ describe('TokowakaClient', () => {
825
1016
  },
826
1017
  }),
827
1018
  },
1019
+ {
1020
+ getId: () => 'sugg-2',
1021
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1022
+ getData: () => ({
1023
+ url: 'https://example.com/page2',
1024
+ recommendedAction: 'New Subtitle',
1025
+ checkType: 'heading-empty',
1026
+ transformRules: {
1027
+ action: 'replace',
1028
+ selector: 'h2',
1029
+ },
1030
+ }),
1031
+ },
1032
+ ];
1033
+
1034
+ const result = await client.deploySuggestions(
1035
+ mockSite,
1036
+ mockOpportunity,
1037
+ mockSuggestions,
1038
+ );
1039
+
1040
+ // Both suggestions are in result but sugg-1 skipped deployment due to no patches
1041
+ expect(result.succeededSuggestions).to.have.length(2);
1042
+ expect(result.s3Paths).to.have.length(1); // Only one URL actually deployed
1043
+ });
1044
+
1045
+ it('should return early when no eligible suggestions', async () => {
1046
+ mockSuggestions = [
1047
+ {
1048
+ getId: () => 'sugg-1',
1049
+ getData: () => ({
1050
+ url: 'https://example.com/page1',
1051
+ recommendedAction: 'New Heading',
1052
+ checkType: 'heading-missing', // Wrong checkType name, not eligible
1053
+ }),
1054
+ },
828
1055
  ];
829
1056
 
830
1057
  const result = await client.deploySuggestions(
@@ -835,6 +1062,7 @@ describe('TokowakaClient', () => {
835
1062
 
836
1063
  expect(result.succeededSuggestions).to.have.length(0);
837
1064
  expect(result.failedSuggestions).to.have.length(1);
1065
+ expect(log.warn).to.have.been.calledWith('No eligible suggestions to deploy');
838
1066
  expect(s3Client.send).to.not.have.been.called;
839
1067
  });
840
1068
 
@@ -846,63 +1074,27 @@ describe('TokowakaClient', () => {
846
1074
  expect.fail('Should have thrown error');
847
1075
  } catch (error) {
848
1076
  expect(error.message).to.include('No mapper found for opportunity type: unsupported-type');
849
- expect(error.message).to.include('Supported types:');
850
1077
  expect(error.status).to.equal(501);
851
1078
  }
852
1079
  });
853
1080
 
854
- it('should handle null tokowakaConfig gracefully', async () => {
855
- mockSite.getConfig = () => ({
856
- getTokowakaConfig: () => null,
857
- });
858
-
859
- try {
860
- await client.deploySuggestions(mockSite, mockOpportunity, mockSuggestions);
861
- expect.fail('Should have thrown error');
862
- } catch (error) {
863
- expect(error.message).to.include('Tokowaka API key configured');
864
- }
865
- });
866
-
867
- it('should use default reason when eligibility has no reason', async () => {
868
- // Create a mock mapper that returns eligible=false without reason
869
- const mockMapper = {
870
- canDeploy: sinon.stub().returns({ eligible: false }), // No reason provided
871
- };
872
- sinon.stub(client.mapperRegistry, 'getMapper').returns(mockMapper);
873
-
874
- const result = await client.deploySuggestions(
875
- mockSite,
876
- mockOpportunity,
877
- mockSuggestions,
878
- );
879
-
880
- expect(result.failedSuggestions).to.have.length(2);
881
- expect(result.failedSuggestions[0].reason).to.equal('Suggestion cannot be deployed');
882
- });
883
-
884
1081
  it('should fetch existing config and merge when deploying', async () => {
885
1082
  const existingConfig = {
886
- siteId: 'site-123',
887
- baseURL: 'https://example.com',
1083
+ url: 'https://example.com/page1',
888
1084
  version: '1.0',
889
- tokowakaForceFail: false,
890
- tokowakaOptimizations: {
891
- '/page1': {
892
- prerender: true,
893
- patches: [
894
- {
895
- op: 'replace',
896
- selector: 'h3',
897
- value: 'Existing Heading',
898
- opportunityId: 'opp-999',
899
- suggestionId: 'sugg-999',
900
- prerenderRequired: true,
901
- lastUpdated: 1234567890,
902
- },
903
- ],
1085
+ forceFail: false,
1086
+ prerender: true,
1087
+ patches: [
1088
+ {
1089
+ op: 'replace',
1090
+ selector: 'h3',
1091
+ value: 'Existing Heading',
1092
+ opportunityId: 'opp-999',
1093
+ suggestionId: 'sugg-999',
1094
+ prerenderRequired: true,
1095
+ lastUpdated: 1234567890,
904
1096
  },
905
- },
1097
+ ],
906
1098
  };
907
1099
 
908
1100
  client.fetchConfig.resolves(existingConfig);
@@ -913,53 +1105,31 @@ describe('TokowakaClient', () => {
913
1105
  mockSuggestions,
914
1106
  );
915
1107
 
916
- expect(client.fetchConfig).to.have.been.calledWith('test-api-key-123');
917
- expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
1108
+ expect(client.fetchConfig).to.have.been.called;
1109
+ expect(result.s3Paths).to.have.length(1);
918
1110
 
919
1111
  // Verify the uploaded config contains both existing and new patches
920
1112
  const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
921
- expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(3);
922
- });
923
-
924
- it('should use new config when no existing config found', async () => {
925
- client.fetchConfig.resolves(null);
926
-
927
- const result = await client.deploySuggestions(
928
- mockSite,
929
- mockOpportunity,
930
- mockSuggestions,
931
- );
932
-
933
- expect(client.fetchConfig).to.have.been.calledWith('test-api-key-123');
934
- expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
935
-
936
- // Verify only new patches are in the config
937
- const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
938
- expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(2);
1113
+ expect(uploadedConfig.patches).to.have.length(3);
939
1114
  });
940
1115
 
941
1116
  it('should update existing patch when deploying same opportunityId and suggestionId', async () => {
942
1117
  const existingConfig = {
943
- siteId: 'site-123',
944
- baseURL: 'https://example.com',
1118
+ url: 'https://example.com/page1',
945
1119
  version: '1.0',
946
- tokowakaForceFail: false,
947
- tokowakaOptimizations: {
948
- '/page1': {
949
- prerender: true,
950
- patches: [
951
- {
952
- op: 'replace',
953
- selector: 'h1',
954
- value: 'Old Heading Value',
955
- opportunityId: 'opp-123',
956
- suggestionId: 'sugg-1',
957
- prerenderRequired: true,
958
- lastUpdated: 1234567890,
959
- },
960
- ],
1120
+ forceFail: false,
1121
+ prerender: true,
1122
+ patches: [
1123
+ {
1124
+ op: 'replace',
1125
+ selector: 'h1',
1126
+ value: 'Old Heading Value',
1127
+ opportunityId: 'opp-123',
1128
+ suggestionId: 'sugg-1',
1129
+ prerenderRequired: true,
1130
+ lastUpdated: 1234567890,
961
1131
  },
962
- },
1132
+ ],
963
1133
  };
964
1134
 
965
1135
  client.fetchConfig.resolves(existingConfig);
@@ -970,152 +1140,788 @@ describe('TokowakaClient', () => {
970
1140
  mockSuggestions,
971
1141
  );
972
1142
 
973
- expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
1143
+ expect(result.s3Paths).to.have.length(1);
974
1144
 
975
1145
  // Verify the patch was updated, not duplicated
976
1146
  const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
977
- expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(2);
1147
+ expect(uploadedConfig.patches).to.have.length(2);
978
1148
 
979
1149
  // First patch should be updated with new value
980
- const updatedPatch = uploadedConfig.tokowakaOptimizations['/page1'].patches[0];
1150
+ const updatedPatch = uploadedConfig.patches[0];
981
1151
  expect(updatedPatch.value).to.equal('New Heading');
982
1152
  expect(updatedPatch.opportunityId).to.equal('opp-123');
983
1153
  expect(updatedPatch.suggestionId).to.equal('sugg-1');
984
1154
  expect(updatedPatch.lastUpdated).to.be.greaterThan(1234567890);
985
1155
  });
1156
+ });
1157
+
1158
+ describe('rollbackSuggestions', () => {
1159
+ beforeEach(() => {
1160
+ // Stub CDN invalidation for rollback tests
1161
+ sinon.stub(client, 'invalidateCdnCache').resolves({
1162
+ status: 'success',
1163
+ provider: 'cloudfront',
1164
+ invalidationId: 'I123',
1165
+ });
1166
+ });
986
1167
 
987
- it('should preserve existing URL paths when merging', async () => {
1168
+ it('should rollback suggestions successfully', async () => {
988
1169
  const existingConfig = {
989
- siteId: 'site-123',
990
- baseURL: 'https://example.com',
1170
+ url: 'https://example.com/page1',
991
1171
  version: '1.0',
992
- tokowakaForceFail: false,
993
- tokowakaOptimizations: {
994
- '/page1': {
995
- prerender: true,
996
- patches: [
997
- {
998
- op: 'replace',
999
- selector: 'h1',
1000
- value: 'Page 1 Heading',
1001
- opportunityId: 'opp-123',
1002
- suggestionId: 'sugg-1',
1003
- prerenderRequired: true,
1004
- lastUpdated: 1234567890,
1005
- },
1006
- ],
1172
+ forceFail: false,
1173
+ prerender: true,
1174
+ patches: [
1175
+ {
1176
+ op: 'replace',
1177
+ selector: 'h1',
1178
+ value: 'Heading 1',
1179
+ opportunityId: 'opp-123',
1180
+ suggestionId: 'sugg-1',
1181
+ prerenderRequired: true,
1182
+ lastUpdated: 1234567890,
1183
+ },
1184
+ {
1185
+ op: 'replace',
1186
+ selector: 'h2',
1187
+ value: 'Heading 2',
1188
+ opportunityId: 'opp-123',
1189
+ suggestionId: 'sugg-2',
1190
+ prerenderRequired: true,
1191
+ lastUpdated: 1234567890,
1007
1192
  },
1008
- '/other-page': {
1009
- prerender: false,
1010
- patches: [
1011
- {
1012
- op: 'replace',
1013
- selector: 'h1',
1014
- value: 'Other Page Heading',
1015
- opportunityId: 'opp-888',
1016
- suggestionId: 'sugg-888',
1017
- prerenderRequired: false,
1018
- lastUpdated: 1234567890,
1019
- },
1020
- ],
1193
+ {
1194
+ op: 'replace',
1195
+ selector: 'h3',
1196
+ value: 'Heading 3',
1197
+ opportunityId: 'opp-123',
1198
+ suggestionId: 'sugg-3',
1199
+ prerenderRequired: true,
1200
+ lastUpdated: 1234567890,
1021
1201
  },
1022
- },
1202
+ ],
1023
1203
  };
1024
1204
 
1025
- client.fetchConfig.resolves(existingConfig);
1205
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1026
1206
 
1027
- const result = await client.deploySuggestions(
1207
+ const result = await client.rollbackSuggestions(
1028
1208
  mockSite,
1029
1209
  mockOpportunity,
1030
- mockSuggestions,
1210
+ mockSuggestions, // Only sugg-1 and sugg-2
1031
1211
  );
1032
1212
 
1033
- expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
1213
+ expect(result.s3Paths).to.have.length(1);
1214
+ expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
1215
+ expect(result.succeededSuggestions).to.have.length(2);
1216
+ expect(result.failedSuggestions).to.have.length(0);
1217
+ expect(result.removedPatchesCount).to.equal(2);
1034
1218
 
1035
- // Verify existing URL paths are preserved
1219
+ // Verify uploaded config has sugg-3 but not sugg-1 and sugg-2
1036
1220
  const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
1037
- expect(uploadedConfig.tokowakaOptimizations).to.have.property('/page1');
1038
- expect(uploadedConfig.tokowakaOptimizations).to.have.property('/other-page');
1039
- expect(uploadedConfig.tokowakaOptimizations['/other-page'].patches[0].value)
1040
- .to.equal('Other Page Heading');
1221
+ expect(uploadedConfig.patches).to.have.length(1);
1222
+ expect(uploadedConfig.patches[0].suggestionId).to.equal('sugg-3');
1041
1223
  });
1042
- });
1043
1224
 
1044
- describe('invalidateCdnCache', () => {
1045
- let mockCdnClient;
1225
+ it('should handle no existing config gracefully', async () => {
1226
+ sinon.stub(client, 'fetchConfig').resolves(null);
1046
1227
 
1047
- beforeEach(() => {
1048
- mockCdnClient = {
1049
- invalidateCache: sinon.stub().resolves({
1050
- status: 'success',
1051
- provider: 'cloudfront',
1052
- invalidationId: 'I123',
1053
- }),
1054
- };
1228
+ const result = await client.rollbackSuggestions(
1229
+ mockSite,
1230
+ mockOpportunity,
1231
+ mockSuggestions,
1232
+ );
1055
1233
 
1056
- sinon.stub(client.cdnClientRegistry, 'getClient').returns(mockCdnClient);
1234
+ // Code continues and marks eligible suggestions as succeeded even if no config found
1235
+ expect(result.succeededSuggestions).to.have.length(2);
1236
+ expect(result.failedSuggestions).to.have.length(0);
1237
+ expect(result.s3Paths).to.have.length(0);
1238
+ expect(s3Client.send).to.not.have.been.called;
1057
1239
  });
1058
1240
 
1059
- it('should invalidate CDN cache successfully', async () => {
1060
- const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
1241
+ it('should handle empty existing config patches', async () => {
1242
+ const existingConfig = {
1243
+ url: 'https://example.com/page1',
1244
+ version: '1.0',
1245
+ forceFail: false,
1246
+ prerender: true,
1247
+ patches: [],
1248
+ };
1061
1249
 
1062
- expect(result).to.deep.equal({
1063
- status: 'success',
1064
- provider: 'cloudfront',
1065
- invalidationId: 'I123',
1066
- });
1250
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1067
1251
 
1068
- expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
1069
- '/opportunities/test-api-key',
1070
- ]);
1071
- expect(log.debug).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
1072
- expect(log.info).to.have.been.calledWith(sinon.match(/CDN cache invalidation completed/));
1252
+ const result = await client.rollbackSuggestions(
1253
+ mockSite,
1254
+ mockOpportunity,
1255
+ mockSuggestions,
1256
+ );
1257
+
1258
+ // Code marks eligible suggestions as succeeded even if no patches to remove
1259
+ expect(result.succeededSuggestions).to.have.length(2);
1260
+ expect(result.failedSuggestions).to.have.length(0);
1261
+ expect(result.s3Paths).to.have.length(0);
1262
+ expect(s3Client.send).to.not.have.been.called;
1263
+ });
1264
+
1265
+ it('should handle suggestions not found in config', async () => {
1266
+ const existingConfig = {
1267
+ url: 'https://example.com/page1',
1268
+ version: '1.0',
1269
+ forceFail: false,
1270
+ prerender: true,
1271
+ patches: [
1272
+ {
1273
+ op: 'replace',
1274
+ selector: 'h1',
1275
+ value: 'Heading',
1276
+ opportunityId: 'opp-123',
1277
+ suggestionId: 'sugg-999', // Different suggestion ID
1278
+ prerenderRequired: true,
1279
+ lastUpdated: 1234567890,
1280
+ },
1281
+ ],
1282
+ };
1283
+
1284
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1285
+
1286
+ const result = await client.rollbackSuggestions(
1287
+ mockSite,
1288
+ mockOpportunity,
1289
+ mockSuggestions,
1290
+ );
1291
+
1292
+ // Code marks eligible suggestions as succeeded even if patches not found
1293
+ expect(result.succeededSuggestions).to.have.length(2);
1294
+ expect(result.failedSuggestions).to.have.length(0);
1295
+ expect(result.s3Paths).to.have.length(0);
1296
+ expect(s3Client.send).to.not.have.been.called;
1297
+ });
1298
+
1299
+ it('should return early when all suggestions are ineligible for rollback', async () => {
1300
+ const ineligibleSuggestions = [
1301
+ {
1302
+ getId: () => 'sugg-1',
1303
+ getData: () => ({
1304
+ url: 'https://example.com/page1',
1305
+ recommendedAction: 'New Heading',
1306
+ checkType: 'heading-missing', // Not eligible
1307
+ }),
1308
+ },
1309
+ {
1310
+ getId: () => 'sugg-2',
1311
+ getData: () => ({
1312
+ url: 'https://example.com/page1',
1313
+ recommendedAction: 'New Subtitle',
1314
+ checkType: 'heading-wrong', // Not eligible
1315
+ }),
1316
+ },
1317
+ ];
1318
+
1319
+ const result = await client.rollbackSuggestions(
1320
+ mockSite,
1321
+ mockOpportunity,
1322
+ ineligibleSuggestions,
1323
+ );
1324
+
1325
+ expect(result.succeededSuggestions).to.have.length(0);
1326
+ expect(result.failedSuggestions).to.have.length(2);
1327
+ expect(s3Client.send).to.not.have.been.called;
1328
+ });
1329
+
1330
+ it('should delete config file when all patches are rolled back', async () => {
1331
+ // Code uploads empty config instead of deleting
1332
+ const existingConfig = {
1333
+ url: 'https://example.com/page1',
1334
+ version: '1.0',
1335
+ forceFail: false,
1336
+ prerender: true,
1337
+ patches: [
1338
+ {
1339
+ op: 'replace',
1340
+ selector: 'h1',
1341
+ value: 'Heading 1',
1342
+ opportunityId: 'opp-123',
1343
+ suggestionId: 'sugg-1',
1344
+ prerenderRequired: true,
1345
+ lastUpdated: 1234567890,
1346
+ },
1347
+ ],
1348
+ };
1349
+
1350
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1351
+
1352
+ const result = await client.rollbackSuggestions(
1353
+ mockSite,
1354
+ mockOpportunity,
1355
+ [mockSuggestions[0]], // Only roll back sugg-1 (all patches for this URL)
1356
+ );
1357
+
1358
+ expect(result.succeededSuggestions).to.have.length(1);
1359
+ expect(result.removedPatchesCount).to.equal(1);
1360
+
1361
+ // Code uploads empty patches array instead of deleting
1362
+ expect(s3Client.send).to.have.been.calledOnce;
1363
+ const command = s3Client.send.firstCall.args[0];
1364
+ expect(command.constructor.name).to.equal('PutObjectCommand');
1365
+ expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
1366
+ });
1367
+
1368
+ it('should handle rollback for multiple URLs', async () => {
1369
+ mockSuggestions = [
1370
+ {
1371
+ getId: () => 'sugg-1',
1372
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1373
+ getData: () => ({
1374
+ url: 'https://example.com/page1',
1375
+ recommendedAction: 'Page 1 Heading',
1376
+ checkType: 'heading-empty',
1377
+ transformRules: {
1378
+ action: 'replace',
1379
+ selector: 'h1',
1380
+ },
1381
+ }),
1382
+ },
1383
+ {
1384
+ getId: () => 'sugg-2',
1385
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1386
+ getData: () => ({
1387
+ url: 'https://example.com/page2',
1388
+ recommendedAction: 'Page 2 Heading',
1389
+ checkType: 'heading-empty',
1390
+ transformRules: {
1391
+ action: 'replace',
1392
+ selector: 'h1',
1393
+ },
1394
+ }),
1395
+ },
1396
+ ];
1397
+
1398
+ const config1 = {
1399
+ url: 'https://example.com/page1',
1400
+ version: '1.0',
1401
+ forceFail: false,
1402
+ prerender: true,
1403
+ patches: [
1404
+ {
1405
+ op: 'replace',
1406
+ selector: 'h1',
1407
+ value: 'Heading 1',
1408
+ opportunityId: 'opp-123',
1409
+ suggestionId: 'sugg-1',
1410
+ prerenderRequired: true,
1411
+ lastUpdated: 1234567890,
1412
+ },
1413
+ ],
1414
+ };
1415
+
1416
+ const config2 = {
1417
+ url: 'https://example.com/page2',
1418
+ version: '1.0',
1419
+ forceFail: false,
1420
+ prerender: true,
1421
+ patches: [
1422
+ {
1423
+ op: 'replace',
1424
+ selector: 'h1',
1425
+ value: 'Heading 2',
1426
+ opportunityId: 'opp-123',
1427
+ suggestionId: 'sugg-2',
1428
+ prerenderRequired: true,
1429
+ lastUpdated: 1234567890,
1430
+ },
1431
+ ],
1432
+ };
1433
+
1434
+ sinon.stub(client, 'fetchConfig')
1435
+ .onFirstCall()
1436
+ .resolves(config1)
1437
+ .onSecondCall()
1438
+ .resolves(config2);
1439
+
1440
+ const result = await client.rollbackSuggestions(
1441
+ mockSite,
1442
+ mockOpportunity,
1443
+ mockSuggestions,
1444
+ );
1445
+
1446
+ expect(result.s3Paths).to.have.length(2);
1447
+ expect(result.cdnInvalidations).to.have.length(2);
1448
+ expect(result.succeededSuggestions).to.have.length(2);
1073
1449
  });
1074
1450
 
1075
- it('should return null if no CDN configuration', async () => {
1451
+ it('should throw error for unsupported opportunity type', async () => {
1452
+ mockOpportunity.getType = () => 'unsupported-type';
1453
+
1076
1454
  try {
1077
- await client.invalidateCdnCache('', 'cloudfront');
1455
+ await client.rollbackSuggestions(mockSite, mockOpportunity, mockSuggestions);
1456
+ expect.fail('Should have thrown error');
1457
+ } catch (error) {
1458
+ expect(error.message).to.include('No mapper found for opportunity type: unsupported-type');
1459
+ expect(error.status).to.equal(501);
1460
+ }
1461
+ });
1462
+
1463
+ it('should remove FAQ heading patch when rolling back last FAQ suggestion', async () => {
1464
+ // Change opportunity to FAQ type
1465
+ mockOpportunity.getType = () => 'faq';
1466
+
1467
+ // Create FAQ suggestion
1468
+ const faqSuggestion = {
1469
+ getId: () => 'faq-sugg-1',
1470
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1471
+ getData: () => ({
1472
+ url: 'https://example.com/page1',
1473
+ shouldOptimize: true,
1474
+ item: {
1475
+ question: 'What is this?',
1476
+ answer: 'This is a FAQ',
1477
+ },
1478
+ transformRules: {
1479
+ action: 'appendChild',
1480
+ selector: 'body',
1481
+ },
1482
+ }),
1483
+ };
1484
+
1485
+ const existingConfig = {
1486
+ url: 'https://example.com/page1',
1487
+ version: '1.0',
1488
+ forceFail: false,
1489
+ prerender: true,
1490
+ patches: [
1491
+ {
1492
+ opportunityId: 'opp-123',
1493
+ // FAQ heading patch (no suggestionId)
1494
+ op: 'appendChild',
1495
+ selector: 'body',
1496
+ value: { type: 'element', tagName: 'h2', children: [{ type: 'text', value: 'FAQs' }] },
1497
+ prerenderRequired: true,
1498
+ lastUpdated: 1234567890,
1499
+ },
1500
+ {
1501
+ opportunityId: 'opp-123',
1502
+ suggestionId: 'faq-sugg-1',
1503
+ op: 'appendChild',
1504
+ selector: 'body',
1505
+ value: { type: 'element', tagName: 'div' },
1506
+ prerenderRequired: true,
1507
+ lastUpdated: 1234567890,
1508
+ },
1509
+ ],
1510
+ };
1511
+
1512
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1513
+
1514
+ const result = await client.rollbackSuggestions(
1515
+ mockSite,
1516
+ mockOpportunity,
1517
+ [faqSuggestion],
1518
+ );
1519
+
1520
+ expect(result.succeededSuggestions).to.have.length(1);
1521
+ expect(result.removedPatchesCount).to.equal(2); // FAQ item + heading
1522
+
1523
+ // Code uploads empty config instead of deleting
1524
+ const command = s3Client.send.firstCall.args[0];
1525
+ expect(command.constructor.name).to.equal('PutObjectCommand');
1526
+ });
1527
+ });
1528
+
1529
+ describe('previewSuggestions', () => {
1530
+ let fetchStub;
1531
+
1532
+ beforeEach(() => {
1533
+ // Stub global fetch for HTML fetching
1534
+ fetchStub = sinon.stub(global, 'fetch');
1535
+ // Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
1536
+ fetchStub.resolves({
1537
+ ok: true,
1538
+ status: 200,
1539
+ statusText: 'OK',
1540
+ headers: {
1541
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
1542
+ },
1543
+ text: async () => '<html><body>Test HTML</body></html>',
1544
+ });
1545
+
1546
+ // Stub CDN invalidation for preview tests
1547
+ sinon.stub(client, 'invalidateCdnCache').resolves({
1548
+ status: 'success',
1549
+ provider: 'cloudfront',
1550
+ invalidationId: 'I123',
1551
+ });
1552
+
1553
+ // Stub fetchConfig to return null by default (no existing config)
1554
+ sinon.stub(client, 'fetchConfig').resolves(null);
1555
+
1556
+ // Add TOKOWAKA_EDGE_URL to env
1557
+ client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
1558
+ });
1559
+
1560
+ afterEach(() => {
1561
+ // fetchStub will be restored by global afterEach sinon.restore()
1562
+ // Just clean up env changes
1563
+ delete client.env.TOKOWAKA_EDGE_URL;
1564
+ });
1565
+
1566
+ it('should preview suggestions successfully with HTML', async () => {
1567
+ const result = await client.previewSuggestions(
1568
+ mockSite,
1569
+ mockOpportunity,
1570
+ mockSuggestions,
1571
+ { warmupDelayMs: 0 },
1572
+ );
1573
+
1574
+ expect(result).to.have.property('s3Path', 'preview/opportunities/example.com/L3BhZ2Ux');
1575
+ expect(result).to.have.property('succeededSuggestions');
1576
+ expect(result.succeededSuggestions).to.have.length(2);
1577
+ expect(result).to.have.property('failedSuggestions');
1578
+ expect(result.failedSuggestions).to.have.length(0);
1579
+ expect(result).to.have.property('html');
1580
+ expect(result.html).to.have.property('url', 'https://example.com/page1');
1581
+ expect(result.html).to.have.property('originalHtml');
1582
+ expect(result.html).to.have.property('optimizedHtml');
1583
+ expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
1584
+ expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
1585
+
1586
+ // Verify fetch was called for HTML fetching
1587
+ // (4 times: warmup + actual for original and optimized)
1588
+ expect(fetchStub.callCount).to.equal(4);
1589
+ expect(s3Client.send).to.have.been.calledOnce;
1590
+ });
1591
+
1592
+ it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
1593
+ delete client.env.TOKOWAKA_EDGE_URL;
1594
+
1595
+ try {
1596
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1597
+ expect.fail('Should have thrown error');
1598
+ } catch (error) {
1599
+ expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
1600
+ expect(error.status).to.equal(500);
1601
+ }
1602
+ });
1603
+
1604
+ it('should throw error if site does not have forwardedHost', async () => {
1605
+ mockSite.getConfig = () => ({
1606
+ getTokowakaConfig: () => ({}),
1607
+ });
1608
+
1609
+ try {
1610
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1078
1611
  expect.fail('Should have thrown error');
1079
1612
  } catch (error) {
1080
- expect(error.message).to.equal('Tokowaka API key and provider are required');
1613
+ expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1081
1614
  expect(error.status).to.equal(400);
1082
1615
  }
1083
1616
  });
1084
1617
 
1085
- it('should return null if CDN config is empty', async () => {
1618
+ it('should throw error if getTokowakaConfig returns null', async () => {
1619
+ mockSite.getConfig = () => ({
1620
+ getTokowakaConfig: () => null,
1621
+ });
1622
+
1086
1623
  try {
1087
- await client.invalidateCdnCache('test-api-key', '');
1624
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1088
1625
  expect.fail('Should have thrown error');
1089
1626
  } catch (error) {
1090
- expect(error.message).to.equal('Tokowaka API key and provider are required');
1627
+ expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
1091
1628
  expect(error.status).to.equal(400);
1092
1629
  }
1093
1630
  });
1094
1631
 
1095
- it('should return null if CDN provider is missing', async () => {
1632
+ it('should throw error for unsupported opportunity type', async () => {
1633
+ mockOpportunity.getType = () => 'unsupported-type';
1634
+
1635
+ try {
1636
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1637
+ expect.fail('Should have thrown error');
1638
+ } catch (error) {
1639
+ expect(error.message).to.include('No mapper found for opportunity type');
1640
+ expect(error.status).to.equal(501);
1641
+ }
1642
+ });
1643
+
1644
+ it('should handle ineligible suggestions', async () => {
1645
+ mockSuggestions = [
1646
+ {
1647
+ getId: () => 'sugg-1',
1648
+ getData: () => ({
1649
+ url: 'https://example.com/page1',
1650
+ recommendedAction: 'New Heading',
1651
+ checkType: 'heading-missing', // Not eligible
1652
+ }),
1653
+ },
1654
+ ];
1655
+
1656
+ const result = await client.previewSuggestions(
1657
+ mockSite,
1658
+ mockOpportunity,
1659
+ mockSuggestions,
1660
+ );
1661
+
1662
+ expect(result.succeededSuggestions).to.have.length(0);
1663
+ expect(result.failedSuggestions).to.have.length(1);
1664
+ expect(result.config).to.be.null;
1665
+ });
1666
+
1667
+ it('should return early when generateConfig returns no patches', async () => {
1668
+ // Stub mapper to return eligible but no patches
1669
+ const mapper = client.mapperRegistry.getMapper('headings');
1670
+ sinon.stub(mapper, 'suggestionsToPatches').returns([]);
1671
+
1672
+ mockSuggestions = [
1673
+ {
1674
+ getId: () => 'sugg-1',
1675
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1676
+ getData: () => ({
1677
+ url: 'https://example.com/page1',
1678
+ recommendedAction: 'New Heading',
1679
+ checkType: 'heading-empty', // Eligible
1680
+ transformRules: {
1681
+ action: 'replace',
1682
+ selector: 'h1',
1683
+ },
1684
+ }),
1685
+ },
1686
+ ];
1687
+
1688
+ const result = await client.previewSuggestions(
1689
+ mockSite,
1690
+ mockOpportunity,
1691
+ mockSuggestions,
1692
+ );
1693
+
1694
+ expect(result.succeededSuggestions).to.have.length(0);
1695
+ expect(result.failedSuggestions).to.have.length(1);
1696
+ expect(result.config).to.be.null;
1697
+ });
1698
+
1699
+ it('should throw error when preview URL not found in suggestion data', async () => {
1700
+ mockSuggestions = [
1701
+ {
1702
+ getId: () => 'sugg-1',
1703
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1704
+ getData: () => ({
1705
+ // URL missing
1706
+ recommendedAction: 'New Heading',
1707
+ checkType: 'heading-empty',
1708
+ transformRules: {
1709
+ action: 'replace',
1710
+ selector: 'h1',
1711
+ },
1712
+ }),
1713
+ },
1714
+ ];
1715
+
1716
+ try {
1717
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1718
+ expect.fail('Should have thrown error');
1719
+ } catch (error) {
1720
+ expect(error.message).to.include('Preview URL not found in suggestion data');
1721
+ expect(error.status).to.equal(400);
1722
+ }
1723
+ });
1724
+
1725
+ it('should throw error when HTML fetch fails', async () => {
1726
+ fetchStub.rejects(new Error('Network timeout'));
1727
+
1728
+ try {
1729
+ await client.previewSuggestions(
1730
+ mockSite,
1731
+ mockOpportunity,
1732
+ mockSuggestions,
1733
+ { warmupDelayMs: 0 },
1734
+ );
1735
+ expect.fail('Should have thrown error');
1736
+ } catch (error) {
1737
+ expect(error.message).to.include('Preview failed: Unable to fetch HTML');
1738
+ expect(error.status).to.equal(500);
1739
+ }
1740
+ });
1741
+
1742
+ it('should merge with existing deployed patches for the same URL', async () => {
1743
+ // Setup existing config with deployed patches
1744
+ const existingConfig = {
1745
+ url: 'https://example.com/page1',
1746
+ version: '1.0',
1747
+ forceFail: false,
1748
+ prerender: true,
1749
+ patches: [
1750
+ {
1751
+ op: 'replace',
1752
+ selector: 'title',
1753
+ value: 'Deployed Title',
1754
+ opportunityId: 'opp-456',
1755
+ suggestionId: 'sugg-deployed',
1756
+ prerenderRequired: true,
1757
+ lastUpdated: 1234567890,
1758
+ },
1759
+ ],
1760
+ };
1761
+
1762
+ client.fetchConfig.resolves(existingConfig);
1763
+
1764
+ const result = await client.previewSuggestions(
1765
+ mockSite,
1766
+ mockOpportunity,
1767
+ mockSuggestions,
1768
+ { warmupDelayMs: 0 },
1769
+ );
1770
+
1771
+ expect(result.succeededSuggestions).to.have.length(2);
1772
+
1773
+ // Verify config was uploaded with merged patches
1774
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
1775
+ expect(uploadedConfig.patches).to.have.length(3);
1776
+
1777
+ // Should have existing deployed patch + 2 new preview patches
1778
+ const deployedPatch = uploadedConfig.patches
1779
+ .find((p) => p.suggestionId === 'sugg-deployed');
1780
+ expect(deployedPatch).to.exist;
1781
+ expect(deployedPatch.value).to.equal('Deployed Title');
1782
+ });
1783
+
1784
+ it('should upload config to preview S3 path', async () => {
1785
+ await client.previewSuggestions(
1786
+ mockSite,
1787
+ mockOpportunity,
1788
+ mockSuggestions,
1789
+ { warmupDelayMs: 0 },
1790
+ );
1791
+
1792
+ expect(s3Client.send).to.have.been.calledOnce;
1793
+
1794
+ const putCommand = s3Client.send.firstCall.args[0];
1795
+ expect(putCommand.input.Bucket).to.equal('test-preview-bucket');
1796
+ expect(putCommand.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
1797
+ });
1798
+
1799
+ it('should invalidate CDN cache for preview path', async () => {
1800
+ await client.previewSuggestions(
1801
+ mockSite,
1802
+ mockOpportunity,
1803
+ mockSuggestions,
1804
+ { warmupDelayMs: 0 },
1805
+ );
1806
+
1807
+ expect(client.invalidateCdnCache).to.have.been.calledOnce;
1808
+ const { firstCall } = client.invalidateCdnCache;
1809
+ expect(firstCall.args[0]).to.equal('https://example.com/page1');
1810
+ expect(firstCall.args[1]).to.equal('cloudfront');
1811
+ expect(firstCall.args[2]).to.be.true; // isPreview
1812
+ });
1813
+
1814
+ it('should throw error if suggestions span multiple URLs', async () => {
1815
+ mockSuggestions = [
1816
+ {
1817
+ getId: () => 'sugg-1',
1818
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1819
+ getData: () => ({
1820
+ url: 'https://example.com/page1',
1821
+ recommendedAction: 'Page 1 Heading',
1822
+ checkType: 'heading-empty',
1823
+ transformRules: {
1824
+ action: 'replace',
1825
+ selector: 'h1',
1826
+ },
1827
+ }),
1828
+ },
1829
+ {
1830
+ getId: () => 'sugg-2',
1831
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1832
+ getData: () => ({
1833
+ url: 'https://example.com/page2', // Different URL
1834
+ recommendedAction: 'Page 2 Heading',
1835
+ checkType: 'heading-empty',
1836
+ transformRules: {
1837
+ action: 'replace',
1838
+ selector: 'h1',
1839
+ },
1840
+ }),
1841
+ },
1842
+ ];
1843
+
1844
+ // Code doesn't validate multi-URL, silently uses first URL
1845
+ // fetchConfig and invalidateCdnCache already stubbed in beforeEach
1846
+ // Only need to stub uploadConfig
1847
+ sinon.stub(client, 'uploadConfig').resolves('preview/opportunities/example.com/L3BhZ2Ux');
1848
+
1849
+ const result = await client.previewSuggestions(
1850
+ mockSite,
1851
+ mockOpportunity,
1852
+ mockSuggestions,
1853
+ { warmupDelayMs: 0 },
1854
+ );
1855
+
1856
+ // Preview succeeds, using first URL only
1857
+ expect(result.succeededSuggestions).to.have.length(2);
1858
+ expect(result.config.url).to.equal('https://example.com/page1');
1859
+ });
1860
+ });
1861
+
1862
+ describe('invalidateCdnCache', () => {
1863
+ let mockCdnClient;
1864
+
1865
+ beforeEach(() => {
1866
+ mockCdnClient = {
1867
+ invalidateCache: sinon.stub().resolves({
1868
+ status: 'success',
1869
+ provider: 'cloudfront',
1870
+ invalidationId: 'I123',
1871
+ }),
1872
+ };
1873
+
1874
+ sinon.stub(client.cdnClientRegistry, 'getClient').returns(mockCdnClient);
1875
+ });
1876
+
1877
+ it('should invalidate CDN cache successfully', async () => {
1878
+ const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
1879
+
1880
+ expect(result).to.deep.equal({
1881
+ status: 'success',
1882
+ provider: 'cloudfront',
1883
+ invalidationId: 'I123',
1884
+ });
1885
+
1886
+ expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
1887
+ '/opportunities/example.com/L3BhZ2Ux',
1888
+ ]);
1889
+ expect(log.debug).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
1890
+ expect(log.info).to.have.been.calledWith(sinon.match(/CDN cache invalidation completed/));
1891
+ });
1892
+
1893
+ it('should invalidate CDN cache for preview path', async () => {
1894
+ await client.invalidateCdnCache('https://example.com/page1', 'cloudfront', true);
1895
+
1896
+ expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
1897
+ '/preview/opportunities/example.com/L3BhZ2Ux',
1898
+ ]);
1899
+ });
1900
+
1901
+ it('should throw error if URL is missing', async () => {
1096
1902
  try {
1097
- await client.invalidateCdnCache('test-api-key', null);
1903
+ await client.invalidateCdnCache('', 'cloudfront');
1098
1904
  expect.fail('Should have thrown error');
1099
1905
  } catch (error) {
1100
- expect(error.message).to.equal('Tokowaka API key and provider are required');
1906
+ expect(error.message).to.equal('URL and provider are required');
1101
1907
  expect(error.status).to.equal(400);
1102
1908
  }
1103
1909
  });
1104
1910
 
1105
- it('should return null if CDN config is missing', async () => {
1911
+ it('should throw error if provider is missing', async () => {
1106
1912
  try {
1107
- await client.invalidateCdnCache(null, 'cloudfront');
1913
+ await client.invalidateCdnCache('https://example.com/page1', '');
1108
1914
  expect.fail('Should have thrown error');
1109
1915
  } catch (error) {
1110
- expect(error.message).to.equal('Tokowaka API key and provider are required');
1916
+ expect(error.message).to.equal('URL and provider are required');
1111
1917
  expect(error.status).to.equal(400);
1112
1918
  }
1113
1919
  });
1114
1920
 
1115
- it('should return null if no CDN client available', async () => {
1921
+ it('should return error object if no CDN client available', async () => {
1116
1922
  client.cdnClientRegistry.getClient.returns(null);
1117
1923
 
1118
- const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
1924
+ const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
1119
1925
 
1120
1926
  expect(result).to.deep.equal({
1121
1927
  status: 'error',
@@ -1128,7 +1934,7 @@ describe('TokowakaClient', () => {
1128
1934
  it('should return error object if CDN invalidation fails', async () => {
1129
1935
  mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
1130
1936
 
1131
- const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
1937
+ const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
1132
1938
 
1133
1939
  expect(result).to.deep.equal({
1134
1940
  status: 'error',