@adobe/spacecat-shared-tokowaka-client 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.2](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.1...@adobe/spacecat-shared-tokowaka-client-v1.5.2) (2026-01-21)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * create and update tokowaka config api ([#1273](https://github.com/adobe/spacecat-shared/issues/1273)) ([0fb6f4a](https://github.com/adobe/spacecat-shared/commit/0fb6f4aaa1efac18803f3890c86d4fc1bc69009f))
7
+
8
+ # [@adobe/spacecat-shared-tokowaka-client-v1.5.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.5.0...@adobe/spacecat-shared-tokowaka-client-v1.5.1) (2026-01-20)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * rename tokowaka to edge-optimize in preview api ([#1269](https://github.com/adobe/spacecat-shared/issues/1269)) ([d40caa4](https://github.com/adobe/spacecat-shared/commit/d40caa46d2bb72506e45081a263f4513fce4ce41))
14
+
1
15
  # [@adobe/spacecat-shared-tokowaka-client-v1.5.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.4.7...@adobe/spacecat-shared-tokowaka-client-v1.5.0) (2026-01-15)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/spacecat-shared-tokowaka-client",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "description": "Tokowaka Client for SpaceCat - Edge optimization config management",
5
5
  "type": "module",
6
6
  "engines": {
package/src/index.js CHANGED
@@ -282,12 +282,12 @@ class TokowakaClient {
282
282
  }
283
283
 
284
284
  /**
285
- * Creates and uploads domain-level metaconfig to S3
285
+ * Creates and uploads domain-level metaconfig to S3 if it does not exists
286
286
  * Generates a new API key and creates the metaconfig structure
287
287
  * @param {string} url - Full URL (used to extract domain)
288
288
  * @param {string} siteId - Site ID
289
289
  * @param {Object} options - Optional configuration
290
- * @param {boolean} options.tokowakaEnabled - Whether to enable Tokowaka (default: true)
290
+ * @param {boolean} options.enhancements - Whether to enable enhancements (default: true)
291
291
  * @returns {Promise<Object>} - Object with s3Path and metaconfig
292
292
  */
293
293
  async createMetaconfig(url, siteId, options = {}) {
@@ -295,6 +295,12 @@ class TokowakaClient {
295
295
  throw this.#createError('URL is required', HTTP_BAD_REQUEST);
296
296
  }
297
297
 
298
+ const existingMetaconfig = await this.fetchMetaconfig(url);
299
+
300
+ if (existingMetaconfig) {
301
+ throw this.#createError('Metaconfig already exists for this URL', HTTP_BAD_REQUEST);
302
+ }
303
+
298
304
  if (!hasText(siteId)) {
299
305
  throw this.#createError('Site ID is required', HTTP_BAD_REQUEST);
300
306
  }
@@ -305,8 +311,53 @@ class TokowakaClient {
305
311
  const metaconfig = {
306
312
  siteId,
307
313
  apiKeys: [apiKey],
314
+ tokowakaEnabled: true,
315
+ enhancements: options.enhancements ?? true,
316
+ patches: {},
317
+ };
318
+
319
+ const s3Path = await this.uploadMetaconfig(url, metaconfig);
320
+
321
+ this.log.info(`Created new Tokowaka metaconfig for ${normalizedHostName} at ${s3Path}`);
322
+
323
+ return metaconfig;
324
+ }
325
+
326
+ /**
327
+ * Updates domain-level metaconfig to S3 if it does not exists
328
+ * Reuses the same API key and updates the metaconfig structure
329
+ * @param {string} url - Full URL (used to extract domain)
330
+ * @param {string} siteId - Site ID
331
+ * @param {Object} options - Optional configuration
332
+ * @returns {Promise<Object>} - Object with s3Path and metaconfig
333
+ */
334
+ async updateMetaconfig(url, siteId, options = {}) {
335
+ if (!hasText(url)) {
336
+ throw this.#createError('URL is required', HTTP_BAD_REQUEST);
337
+ }
338
+
339
+ const existingMetaconfig = await this.fetchMetaconfig(url);
340
+ if (!existingMetaconfig) {
341
+ throw this.#createError('Metaconfig does not exist for this URL', HTTP_BAD_REQUEST);
342
+ }
343
+
344
+ if (!hasText(siteId)) {
345
+ throw this.#createError('Site ID is required', HTTP_BAD_REQUEST);
346
+ }
347
+
348
+ const normalizedHostName = getHostName(url, this.log);
349
+
350
+ // dont override api keys
351
+ // if patches exist, they cannot reset to empty object
352
+ const metaconfig = {
353
+ siteId,
354
+ apiKeys: existingMetaconfig.apiKeys,
308
355
  tokowakaEnabled: options.tokowakaEnabled ?? true,
309
- enhancements: false,
356
+ enhancements: options.enhancements ?? true,
357
+ patches: isNonEmptyObject(options.patches)
358
+ ? options.patches
359
+ : (existingMetaconfig.patches ?? {}),
360
+ ...(options.forceFail && { forceFail: true }),
310
361
  };
311
362
 
312
363
  const s3Path = await this.uploadMetaconfig(url, metaconfig);
@@ -810,8 +861,8 @@ class TokowakaClient {
810
861
  }
811
862
 
812
863
  // TOKOWAKA_EDGE_URL is mandatory for preview
813
- const tokowakaEdgeUrl = this.env.TOKOWAKA_EDGE_URL;
814
- if (!hasText(tokowakaEdgeUrl)) {
864
+ const edgeUrl = this.env.TOKOWAKA_EDGE_URL;
865
+ if (!hasText(edgeUrl)) {
815
866
  throw this.#createError(
816
867
  'TOKOWAKA_EDGE_URL is required for preview functionality',
817
868
  HTTP_INTERNAL_SERVER_ERROR,
@@ -931,7 +982,7 @@ class TokowakaClient {
931
982
  previewUrl,
932
983
  apiKey,
933
984
  forwardedHost,
934
- tokowakaEdgeUrl,
985
+ edgeUrl,
935
986
  this.log,
936
987
  false,
937
988
  options,
@@ -941,7 +992,7 @@ class TokowakaClient {
941
992
  previewUrl,
942
993
  apiKey,
943
994
  forwardedHost,
944
- tokowakaEdgeUrl,
995
+ edgeUrl,
945
996
  this.log,
946
997
  true,
947
998
  options,
@@ -25,7 +25,7 @@ function sleep(ms) {
25
25
 
26
26
  /**
27
27
  * Makes an HTTP request with retry logic
28
- * Retries until max retries are exhausted or x-tokowaka-cache header is present
28
+ * Retries until max retries are exhausted or x-edge-optimize-cache header is present
29
29
  * @param {string} url - URL to fetch
30
30
  * @param {Object} options - Fetch options
31
31
  * @param {number} maxRetries - Maximum number of retries
@@ -48,10 +48,10 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
48
48
  throw new Error(`HTTP ${response.status}: ${response.statusText}`);
49
49
  }
50
50
 
51
- // Check for x-tokowaka-cache header - if present, stop retrying
52
- const cacheHeader = response.headers.get('x-tokowaka-cache');
51
+ // Check for x-edge-optimize-cache header - if present, stop retrying
52
+ const cacheHeader = response.headers.get('x-edge-optimize-cache');
53
53
  if (cacheHeader) {
54
- log.debug(`Cache header found (x-tokowaka-cache: ${cacheHeader}), stopping retry logic`);
54
+ log.debug(`Cache header found (x-edge-optimize-cache: ${cacheHeader}), stopping retry logic`);
55
55
  return response;
56
56
  }
57
57
 
@@ -65,7 +65,7 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
65
65
  } else {
66
66
  // Last attempt without cache header - throw error
67
67
  log.error(`Max retries (${maxRetries}) exhausted without cache header`);
68
- throw new Error(`Cache header (x-tokowaka-cache) not found after ${maxRetries} retries`);
68
+ throw new Error(`Cache header (x-edge-optimize-cache) not found after ${maxRetries} retries`);
69
69
  }
70
70
  } catch (error) {
71
71
  log.warn(`Attempt ${attempt} failed for ${fetchType} HTML, error: ${error.message}`);
@@ -86,12 +86,12 @@ async function fetchWithRetry(url, options, maxRetries, retryDelayMs, log, fetch
86
86
  }
87
87
 
88
88
  /**
89
- * Fetches HTML content from Tokowaka edge with warmup call and retry logic
89
+ * Fetches HTML content from edge with warmup call and retry logic
90
90
  * Makes an initial warmup call, waits, then makes the actual call with retries
91
91
  * @param {string} url - Full URL to fetch
92
- * @param {string} apiKey - Tokowaka API key
92
+ * @param {string} apiKey - Edge Optimize API key
93
93
  * @param {string} forwardedHost - Host to forward in x-forwarded-host header
94
- * @param {string} tokowakaEdgeUrl - Tokowaka edge URL
94
+ * @param {string} edgeUrl - Edge URL
95
95
  * @param {boolean} isOptimized - Whether to fetch optimized HTML (with preview param)
96
96
  * @param {Object} log - Logger instance
97
97
  * @param {Object} options - Additional options
@@ -105,7 +105,7 @@ export async function fetchHtmlWithWarmup(
105
105
  url,
106
106
  apiKey,
107
107
  forwardedHost,
108
- tokowakaEdgeUrl,
108
+ edgeUrl,
109
109
  log,
110
110
  isOptimized = false,
111
111
  options = {},
@@ -116,14 +116,14 @@ export async function fetchHtmlWithWarmup(
116
116
  }
117
117
 
118
118
  if (!hasText(apiKey)) {
119
- throw new Error('Tokowaka API key is required for fetching HTML');
119
+ throw new Error('Edge Optimize API key is required for fetching HTML');
120
120
  }
121
121
 
122
122
  if (!hasText(forwardedHost)) {
123
123
  throw new Error('Forwarded host is required for fetching HTML');
124
124
  }
125
125
 
126
- if (!hasText(tokowakaEdgeUrl)) {
126
+ if (!hasText(edgeUrl)) {
127
127
  throw new Error('TOKOWAKA_EDGE_URL is not configured');
128
128
  }
129
129
 
@@ -139,18 +139,18 @@ export async function fetchHtmlWithWarmup(
139
139
  // Parse the URL to extract path and construct full URL
140
140
  const urlObj = new URL(url);
141
141
  const urlPath = urlObj.pathname;
142
- let fullUrl = `${tokowakaEdgeUrl}${urlPath}`;
142
+ let fullUrl = `${edgeUrl}${urlPath}`;
143
143
 
144
144
  const headers = {
145
145
  'x-forwarded-host': forwardedHost,
146
- 'x-tokowaka-api-key': apiKey,
147
- 'x-tokowaka-url': urlPath,
146
+ 'x-edge-optimize-api-key': apiKey,
147
+ 'x-edge-optimize-url': urlPath,
148
148
  };
149
149
 
150
150
  if (isOptimized) {
151
151
  // Add tokowakaPreview param for optimized HTML
152
152
  fullUrl = `${fullUrl}?tokowakaPreview=true`;
153
- headers['x-tokowaka-url'] = `${urlPath}?tokowakaPreview=true`;
153
+ headers['x-edge-optimize-url'] = `${urlPath}?tokowakaPreview=true`;
154
154
  }
155
155
 
156
156
  const fetchOptions = {
@@ -457,9 +457,12 @@ describe('TokowakaClient', () => {
457
457
  });
458
458
 
459
459
  describe('createMetaconfig', () => {
460
- it('should create metaconfig with generated API key and default options', async () => {
460
+ it('should create the default metaconfig with generated API key', async () => {
461
461
  const siteId = 'site-123';
462
462
  const url = 'https://www.example.com/page1';
463
+ const noSuchKeyError = new Error('NoSuchKey');
464
+ noSuchKeyError.name = 'NoSuchKey';
465
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
463
466
 
464
467
  const result = await client.createMetaconfig(url, siteId);
465
468
 
@@ -468,31 +471,47 @@ describe('TokowakaClient', () => {
468
471
  expect(result.apiKeys).to.be.an('array').with.lengthOf(1);
469
472
  expect(result.apiKeys[0]).to.be.a('string');
470
473
  expect(result).to.have.property('tokowakaEnabled', true);
471
- expect(result).to.have.property('enhancements', false);
474
+ expect(result).to.have.property('enhancements', true);
475
+ expect(result.patches).to.be.empty;
472
476
 
473
477
  // Verify uploadMetaconfig was called with correct metaconfig
474
- expect(s3Client.send).to.have.been.calledOnce;
478
+ expect(s3Client.send).to.have.been.calledTwice;
475
479
  const command = s3Client.send.firstCall.args[0];
476
480
  expect(command.input.Bucket).to.equal('test-bucket');
477
481
  expect(command.input.Key).to.equal('opportunities/example.com/config');
478
482
  });
479
483
 
480
- it('should create metaconfig with tokowakaEnabled set to false', async () => {
481
- const siteId = 'site-123';
482
- const url = 'https://example.com';
483
-
484
- const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: false });
485
-
486
- expect(result).to.have.property('tokowakaEnabled', false);
487
- expect(result).to.have.property('enhancements', false);
488
- expect(result.apiKeys).to.have.lengthOf(1);
484
+ it('should throw error if metaconfig exists', async () => {
485
+ const existingMetaconfig = {
486
+ siteId: 'site-123',
487
+ apiKeys: ['existing-api-key-123'],
488
+ tokowakaEnabled: true,
489
+ enhancements: true,
490
+ patches: {},
491
+ };
492
+ // Mock fetchMetaconfig to return existing config
493
+ s3Client.send.onFirstCall().resolves({
494
+ Body: {
495
+ transformToString: sinon.stub().resolves(JSON.stringify(existingMetaconfig)),
496
+ },
497
+ });
498
+ try {
499
+ await client.createMetaconfig('https://example.com', 'site-123');
500
+ expect.fail('Should have thrown error');
501
+ } catch (error) {
502
+ expect(error.message).to.include('Metaconfig already exists for this URL');
503
+ expect(error.status).to.equal(400);
504
+ }
489
505
  });
490
506
 
491
- it('should create metaconfig with tokowakaEnabled set to true explicitly', async () => {
492
- const siteId = 'site-123';
507
+ it('should create metaconfig with enhancements set to false', async () => {
508
+ const siteId = 'site-789';
493
509
  const url = 'https://example.com';
510
+ const noSuchKeyError = new Error('NoSuchKey');
511
+ noSuchKeyError.name = 'NoSuchKey';
512
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
494
513
 
495
- const result = await client.createMetaconfig(url, siteId, { tokowakaEnabled: true });
514
+ const result = await client.createMetaconfig(url, siteId, { enhancements: false });
496
515
 
497
516
  expect(result).to.have.property('tokowakaEnabled', true);
498
517
  expect(result).to.have.property('enhancements', false);
@@ -509,6 +528,9 @@ describe('TokowakaClient', () => {
509
528
  });
510
529
 
511
530
  it('should throw error if siteId is missing', async () => {
531
+ const noSuchKeyError = new Error('NoSuchKey');
532
+ noSuchKeyError.name = 'NoSuchKey';
533
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
512
534
  try {
513
535
  await client.createMetaconfig('https://example.com', '');
514
536
  expect.fail('Should have thrown error');
@@ -519,9 +541,11 @@ describe('TokowakaClient', () => {
519
541
  });
520
542
 
521
543
  it('should handle S3 upload failure', async () => {
544
+ const noSuchKeyError = new Error('NoSuchKey');
545
+ noSuchKeyError.name = 'NoSuchKey';
546
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
522
547
  const s3Error = new Error('S3 network error');
523
- s3Client.send.rejects(s3Error);
524
-
548
+ s3Client.send.onSecondCall().rejects(s3Error);
525
549
  try {
526
550
  await client.createMetaconfig('https://example.com', 'site-123');
527
551
  expect.fail('Should have thrown error');
@@ -534,6 +558,9 @@ describe('TokowakaClient', () => {
534
558
  it('should strip www. from domain in metaconfig path', async () => {
535
559
  const siteId = 'site-123';
536
560
  const url = 'https://www.example.com/some/path';
561
+ const noSuchKeyError = new Error('NoSuchKey');
562
+ noSuchKeyError.name = 'NoSuchKey';
563
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
537
564
 
538
565
  await client.createMetaconfig(url, siteId);
539
566
 
@@ -542,6 +569,294 @@ describe('TokowakaClient', () => {
542
569
  });
543
570
  });
544
571
 
572
+ describe('updateMetaconfig', () => {
573
+ const existingMetaconfig = {
574
+ siteId: 'site-456',
575
+ apiKeys: ['existing-api-key-123'],
576
+ tokowakaEnabled: false,
577
+ enhancements: false,
578
+ patches: { 'existing-patch': 'value' },
579
+ };
580
+
581
+ beforeEach(() => {
582
+ // Mock fetchMetaconfig to return existing config
583
+ s3Client.send.onFirstCall().resolves({
584
+ Body: {
585
+ transformToString: sinon.stub().resolves(JSON.stringify(existingMetaconfig)),
586
+ },
587
+ });
588
+ // Mock uploadMetaconfig S3 upload
589
+ s3Client.send.onSecondCall().resolves();
590
+ });
591
+
592
+ it('should update metaconfig with default options', async () => {
593
+ const siteId = 'site-789';
594
+ const url = 'https://www.example.com/page1';
595
+
596
+ const result = await client.updateMetaconfig(url, siteId);
597
+
598
+ expect(result).to.have.property('siteId', siteId);
599
+ expect(result).to.have.property('apiKeys');
600
+ expect(result.apiKeys).to.deep.equal(['existing-api-key-123']);
601
+ expect(result).to.have.property('tokowakaEnabled', true);
602
+ expect(result).to.have.property('enhancements', true);
603
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
604
+ expect(result).to.not.have.property('forceFail');
605
+ });
606
+
607
+ it('should update metaconfig with tokowakaEnabled set to false', async () => {
608
+ const siteId = 'site-789';
609
+ const url = 'https://example.com';
610
+
611
+ const result = await client.updateMetaconfig(url, siteId, { tokowakaEnabled: false });
612
+
613
+ expect(result).to.have.property('tokowakaEnabled', false);
614
+ expect(result).to.have.property('enhancements', true);
615
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
616
+ expect(result).to.not.have.property('forceFail');
617
+ });
618
+
619
+ it('should update metaconfig with tokowakaEnabled set to true explicitly', async () => {
620
+ const siteId = 'site-789';
621
+ const url = 'https://example.com';
622
+
623
+ const result = await client.updateMetaconfig(url, siteId, { tokowakaEnabled: true });
624
+
625
+ expect(result).to.have.property('tokowakaEnabled', true);
626
+ expect(result).to.have.property('enhancements', true);
627
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
628
+ });
629
+
630
+ it('should update metaconfig with enhancements set to false', async () => {
631
+ const siteId = 'site-789';
632
+ const url = 'https://example.com';
633
+
634
+ const result = await client.updateMetaconfig(url, siteId, { enhancements: false });
635
+
636
+ expect(result).to.have.property('tokowakaEnabled', true);
637
+ expect(result).to.have.property('enhancements', false);
638
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
639
+ });
640
+
641
+ it('should update metaconfig with enhancements set to true explicitly', async () => {
642
+ const siteId = 'site-789';
643
+ const url = 'https://example.com';
644
+
645
+ const result = await client.updateMetaconfig(url, siteId, { enhancements: true });
646
+
647
+ expect(result).to.have.property('tokowakaEnabled', true);
648
+ expect(result).to.have.property('enhancements', true);
649
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
650
+ });
651
+
652
+ it('should override patches when non-empty patches object is provided', async () => {
653
+ const siteId = 'site-789';
654
+ const url = 'https://example.com';
655
+ const newPatches = { 'new-patch': 'new-value', 'another-patch': 'another-value' };
656
+
657
+ const result = await client.updateMetaconfig(url, siteId, { patches: newPatches });
658
+
659
+ expect(result.patches).to.deep.equal(newPatches);
660
+ expect(result.patches).to.not.deep.equal({ 'existing-patch': 'value' });
661
+ });
662
+
663
+ it('should preserve existing patches when empty patches object is provided', async () => {
664
+ const siteId = 'site-789';
665
+ const url = 'https://example.com';
666
+
667
+ const result = await client.updateMetaconfig(url, siteId, { patches: {} });
668
+
669
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
670
+ });
671
+
672
+ it('should preserve existing patches when patches is undefined', async () => {
673
+ const siteId = 'site-789';
674
+ const url = 'https://example.com';
675
+
676
+ const result = await client.updateMetaconfig(url, siteId);
677
+
678
+ expect(result.patches).to.deep.equal({ 'existing-patch': 'value' });
679
+ });
680
+
681
+ it('should use empty patches object when existing config has no patches and no patches provided', async () => {
682
+ const configWithoutPatches = {
683
+ siteId: 'site-456',
684
+ apiKeys: ['existing-api-key-123'],
685
+ tokowakaEnabled: false,
686
+ enhancements: false,
687
+ };
688
+ s3Client.send.onFirstCall().resolves({
689
+ Body: {
690
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithoutPatches)),
691
+ },
692
+ });
693
+
694
+ const siteId = 'site-789';
695
+ const url = 'https://example.com';
696
+
697
+ const result = await client.updateMetaconfig(url, siteId);
698
+
699
+ expect(result.patches).to.deep.equal({});
700
+ });
701
+
702
+ it('should include forceFail when set to true', async () => {
703
+ const siteId = 'site-789';
704
+ const url = 'https://example.com';
705
+
706
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: true });
707
+
708
+ expect(result).to.have.property('forceFail', true);
709
+ });
710
+
711
+ it('should not include forceFail when set to false', async () => {
712
+ const siteId = 'site-789';
713
+ const url = 'https://example.com';
714
+
715
+ const result = await client.updateMetaconfig(url, siteId, { forceFail: false });
716
+
717
+ expect(result).to.not.have.property('forceFail');
718
+ });
719
+
720
+ it('should not include forceFail when undefined', async () => {
721
+ const siteId = 'site-789';
722
+ const url = 'https://example.com';
723
+
724
+ const result = await client.updateMetaconfig(url, siteId);
725
+
726
+ expect(result).to.not.have.property('forceFail');
727
+ });
728
+
729
+ it('should update metaconfig with multiple options', async () => {
730
+ const siteId = 'site-789';
731
+ const url = 'https://example.com';
732
+ const newPatches = { 'custom-patch': 'custom-value' };
733
+
734
+ const result = await client.updateMetaconfig(url, siteId, {
735
+ tokowakaEnabled: false,
736
+ enhancements: false,
737
+ patches: newPatches,
738
+ forceFail: true,
739
+ });
740
+
741
+ expect(result).to.have.property('tokowakaEnabled', false);
742
+ expect(result).to.have.property('enhancements', false);
743
+ expect(result.patches).to.deep.equal(newPatches);
744
+ expect(result).to.have.property('forceFail', true);
745
+ expect(result.apiKeys).to.deep.equal(['existing-api-key-123']);
746
+ });
747
+
748
+ it('should preserve apiKeys from existing metaconfig', async () => {
749
+ const existingWithMultipleKeys = {
750
+ siteId: 'site-456',
751
+ apiKeys: ['key-1', 'key-2', 'key-3'],
752
+ tokowakaEnabled: true,
753
+ enhancements: true,
754
+ patches: {},
755
+ };
756
+ s3Client.send.onFirstCall().resolves({
757
+ Body: {
758
+ transformToString: sinon.stub().resolves(JSON.stringify(existingWithMultipleKeys)),
759
+ },
760
+ });
761
+
762
+ const siteId = 'site-789';
763
+ const url = 'https://example.com';
764
+
765
+ const result = await client.updateMetaconfig(url, siteId);
766
+
767
+ expect(result.apiKeys).to.deep.equal(['key-1', 'key-2', 'key-3']);
768
+ });
769
+
770
+ it('should throw error if URL is missing', async () => {
771
+ try {
772
+ await client.updateMetaconfig('', 'site-123');
773
+ expect.fail('Should have thrown error');
774
+ } catch (error) {
775
+ expect(error.message).to.include('URL is required');
776
+ expect(error.status).to.equal(400);
777
+ }
778
+ });
779
+
780
+ it('should throw error if siteId is missing', async () => {
781
+ try {
782
+ await client.updateMetaconfig('https://example.com', '');
783
+ expect.fail('Should have thrown error');
784
+ } catch (error) {
785
+ expect(error.message).to.include('Site ID is required');
786
+ expect(error.status).to.equal(400);
787
+ }
788
+ });
789
+
790
+ it('should throw error if metaconfig does not exist', async () => {
791
+ const noSuchKeyError = new Error('NoSuchKey');
792
+ noSuchKeyError.name = 'NoSuchKey';
793
+ s3Client.send.onFirstCall().rejects(noSuchKeyError);
794
+
795
+ try {
796
+ await client.updateMetaconfig('https://example.com', 'site-123');
797
+ expect.fail('Should have thrown error');
798
+ } catch (error) {
799
+ expect(error.message).to.include('Metaconfig does not exist for this URL');
800
+ expect(error.status).to.equal(400);
801
+ }
802
+ });
803
+
804
+ it('should handle S3 upload failure', async () => {
805
+ const s3Error = new Error('S3 network error');
806
+ s3Client.send.onSecondCall().rejects(s3Error);
807
+
808
+ try {
809
+ await client.updateMetaconfig('https://example.com', 'site-123');
810
+ expect.fail('Should have thrown error');
811
+ } catch (error) {
812
+ expect(error.message).to.include('S3 upload failed');
813
+ expect(error.status).to.equal(500);
814
+ }
815
+ });
816
+
817
+ it('should strip www. from domain in metaconfig path', async () => {
818
+ const siteId = 'site-789';
819
+ const url = 'https://www.example.com/some/path';
820
+
821
+ await client.updateMetaconfig(url, siteId);
822
+
823
+ const uploadCommand = s3Client.send.secondCall.args[0];
824
+ expect(uploadCommand.input.Key).to.equal('opportunities/example.com/config');
825
+ });
826
+
827
+ it('should handle metaconfig with null patches', async () => {
828
+ const configWithNullPatches = {
829
+ siteId: 'site-456',
830
+ apiKeys: ['existing-api-key-123'],
831
+ tokowakaEnabled: true,
832
+ enhancements: true,
833
+ patches: null,
834
+ };
835
+ s3Client.send.onFirstCall().resolves({
836
+ Body: {
837
+ transformToString: sinon.stub().resolves(JSON.stringify(configWithNullPatches)),
838
+ },
839
+ });
840
+
841
+ const siteId = 'site-789';
842
+ const url = 'https://example.com';
843
+
844
+ const result = await client.updateMetaconfig(url, siteId);
845
+
846
+ expect(result.patches).to.deep.equal({});
847
+ });
848
+
849
+ it('should handle single patch in options.patches', async () => {
850
+ const siteId = 'site-789';
851
+ const url = 'https://example.com';
852
+ const singlePatch = { 'only-patch': 'only-value' };
853
+
854
+ const result = await client.updateMetaconfig(url, siteId, { patches: singlePatch });
855
+
856
+ expect(result.patches).to.deep.equal(singlePatch);
857
+ });
858
+ });
859
+
545
860
  describe('uploadConfig', () => {
546
861
  it('should upload config to S3', async () => {
547
862
  const config = {
@@ -1924,7 +2239,7 @@ describe('TokowakaClient', () => {
1924
2239
  status: 200,
1925
2240
  statusText: 'OK',
1926
2241
  headers: {
1927
- get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
2242
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
1928
2243
  },
1929
2244
  text: async () => '<html><body>Test HTML</body></html>',
1930
2245
  });
@@ -744,7 +744,6 @@ Overall, Bulk positions itself as a better choice for sports nutrition through i
744
744
  question: 'Old Q?',
745
745
  answer: 'Old A.',
746
746
  },
747
- tokowakaDeployed: 1704884400000,
748
747
  transformRules: {
749
748
  action: 'appendChild',
750
749
  selector: 'main',
@@ -1154,7 +1153,7 @@ Overall, Bulk positions itself as a better choice for sports nutrition through i
1154
1153
  });
1155
1154
  });
1156
1155
 
1157
- describe('tokowakaDeployed filtering', () => {
1156
+ describe('edgeDeployed filtering', () => {
1158
1157
  it('should always create heading patch even when FAQ already deployed for URL', () => {
1159
1158
  const newSuggestion = {
1160
1159
  getId: () => 'sugg-new-1',
@@ -94,7 +94,7 @@ describe('HTML Utils', () => {
94
94
  );
95
95
  expect.fail('Should have thrown error');
96
96
  } catch (error) {
97
- expect(error.message).to.equal('Tokowaka API key is required for fetching HTML');
97
+ expect(error.message).to.equal('Edge Optimize API key is required for fetching HTML');
98
98
  }
99
99
  });
100
100
 
@@ -104,7 +104,7 @@ describe('HTML Utils', () => {
104
104
  status: 200,
105
105
  statusText: 'OK',
106
106
  headers: {
107
- get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
107
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
108
108
  },
109
109
  text: async () => '<html>Test HTML</html>',
110
110
  });
@@ -129,7 +129,7 @@ describe('HTML Utils', () => {
129
129
  status: 200,
130
130
  statusText: 'OK',
131
131
  headers: {
132
- get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
132
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
133
133
  },
134
134
  text: async () => '<html>Optimized HTML</html>',
135
135
  });
@@ -285,7 +285,7 @@ describe('HTML Utils', () => {
285
285
  }
286
286
  });
287
287
 
288
- it('should stop retrying when x-tokowaka-cache header is found', async () => {
288
+ it('should stop retrying when x-edge-optimize-cache header is found', async () => {
289
289
  // Warmup succeeds
290
290
  fetchStub.onCall(0).resolves({
291
291
  ok: true,
@@ -312,7 +312,7 @@ describe('HTML Utils', () => {
312
312
  status: 200,
313
313
  statusText: 'OK',
314
314
  headers: {
315
- get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
315
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
316
316
  },
317
317
  text: async () => '<html>Cached HTML</html>',
318
318
  });
@@ -385,7 +385,7 @@ describe('HTML Utils', () => {
385
385
  expect.fail('Should have thrown error');
386
386
  } catch (error) {
387
387
  expect(error.message).to.include('Failed to fetch original HTML');
388
- expect(error.message).to.include('Cache header (x-tokowaka-cache) not found after 2 retries');
388
+ expect(error.message).to.include('Cache header (x-edge-optimize-cache) not found after 2 retries');
389
389
  }
390
390
 
391
391
  // Should have tried 3 times (initial + 2 retries) plus warmup
@@ -409,7 +409,7 @@ describe('HTML Utils', () => {
409
409
  status: 200,
410
410
  statusText: 'OK',
411
411
  headers: {
412
- get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
412
+ get: (name) => (name === 'x-edge-optimize-cache' ? 'HIT' : null),
413
413
  },
414
414
  text: async () => '<html>Cached HTML</html>',
415
415
  });