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