@adobe/spacecat-shared-tokowaka-client 1.4.0 → 1.4.1

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.
@@ -368,25 +368,6 @@ describe('TokowakaClient', () => {
368
368
  expect(command.input.Key).to.equal('opportunities/example.com/config');
369
369
  });
370
370
 
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),
380
- },
381
- });
382
-
383
- await client.fetchMetaconfig('https://example.com/page1', true);
384
-
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');
388
- });
389
-
390
371
  it('should return null if metaconfig does not exist', async () => {
391
372
  const noSuchKeyError = new Error('NoSuchKey');
392
373
  noSuchKeyError.name = 'NoSuchKey';
@@ -441,20 +422,6 @@ describe('TokowakaClient', () => {
441
422
  expect(JSON.parse(command.input.Body)).to.deep.equal(metaconfig);
442
423
  });
443
424
 
444
- it('should upload metaconfig to preview bucket', async () => {
445
- const metaconfig = {
446
- siteId: 'site-123',
447
- prerender: true,
448
- };
449
-
450
- const s3Path = await client.uploadMetaconfig('https://example.com/page1', metaconfig, true);
451
-
452
- expect(s3Path).to.equal('preview/opportunities/example.com/config');
453
-
454
- const command = s3Client.send.firstCall.args[0];
455
- expect(command.input.Bucket).to.equal('test-preview-bucket');
456
- });
457
-
458
425
  it('should throw error if URL is missing', async () => {
459
426
  try {
460
427
  await client.uploadMetaconfig('', { siteId: 'site-123', prerender: true });
@@ -810,16 +777,19 @@ describe('TokowakaClient', () => {
810
777
 
811
778
  describe('deploySuggestions', () => {
812
779
  beforeEach(() => {
813
- // Stub CDN invalidation for deploy tests
814
- sinon.stub(client, 'invalidateCdnCache').resolves({
780
+ // Stub CDN invalidation for deploy tests (now handles both single and batch)
781
+ sinon.stub(client, 'invalidateCdnCache').resolves([{
815
782
  status: 'success',
816
783
  provider: 'cloudfront',
817
784
  invalidationId: 'I123',
818
- });
785
+ }]);
819
786
  // Stub fetchConfig to return null by default (no existing config)
820
787
  sinon.stub(client, 'fetchConfig').resolves(null);
821
- // Stub fetchMetaconfig to return null by default (will create new)
822
- sinon.stub(client, 'fetchMetaconfig').resolves(null);
788
+ // Stub fetchMetaconfig to return existing metaconfig (required for deployment)
789
+ sinon.stub(client, 'fetchMetaconfig').resolves({
790
+ siteId: 'site-123',
791
+ prerender: true,
792
+ });
823
793
  // Stub uploadMetaconfig
824
794
  sinon.stub(client, 'uploadMetaconfig').resolves('opportunities/example.com/config');
825
795
  });
@@ -835,43 +805,28 @@ describe('TokowakaClient', () => {
835
805
  expect(result.s3Paths).to.be.an('array').with.length(1);
836
806
  expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
837
807
  expect(result).to.have.property('cdnInvalidations');
808
+ // Only 1 invalidation result returned (for batch URLs)
809
+ // Metaconfig invalidation happens inside uploadMetaconfig() automatically
838
810
  expect(result.cdnInvalidations).to.be.an('array').with.length(1);
839
811
  expect(result.succeededSuggestions).to.have.length(2);
840
812
  expect(result.failedSuggestions).to.have.length(0);
841
813
  expect(s3Client.send).to.have.been.called;
842
814
  });
843
815
 
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;
816
+ it('should throw error if metaconfig does not exist', async () => {
817
+ client.fetchMetaconfig.resolves(null);
853
818
 
854
- const metaconfigArg = client.uploadMetaconfig.firstCall.args[1];
855
- expect(metaconfigArg).to.deep.include({
856
- siteId: 'site-123',
857
- prerender: true,
858
- });
859
- });
860
-
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;
819
+ try {
820
+ await client.deploySuggestions(
821
+ mockSite,
822
+ mockOpportunity,
823
+ mockSuggestions,
824
+ );
825
+ expect.fail('Should have thrown error');
826
+ } catch (error) {
827
+ expect(error.message).to.include('No domain-level metaconfig found');
828
+ expect(error.status).to.equal(400);
829
+ }
875
830
  });
876
831
 
877
832
  it('should handle suggestions for multiple URLs', async () => {
@@ -911,7 +866,9 @@ describe('TokowakaClient', () => {
911
866
  );
912
867
 
913
868
  expect(result.s3Paths).to.have.length(2);
914
- expect(result.cdnInvalidations).to.have.length(2);
869
+ // Only 1 invalidation result returned (for batch URLs)
870
+ // Metaconfig invalidation happens inside uploadMetaconfig() automatically
871
+ expect(result.cdnInvalidations).to.have.length(1);
915
872
  expect(result.succeededSuggestions).to.have.length(2);
916
873
  });
917
874
 
@@ -1147,6 +1104,8 @@ describe('TokowakaClient', () => {
1147
1104
  expect(result.succeededSuggestions).to.have.length(1);
1148
1105
  expect(result.failedSuggestions).to.have.length(0);
1149
1106
  expect(result.s3Paths).to.have.length(1);
1107
+ // Only 1 invalidation result returned (for batch URLs)
1108
+ // Metaconfig invalidation happens inside uploadMetaconfig() automatically
1150
1109
  expect(result.cdnInvalidations).to.have.length(1);
1151
1110
 
1152
1111
  // Verify uploaded config has no patches but prerender is enabled
@@ -1155,12 +1114,12 @@ describe('TokowakaClient', () => {
1155
1114
  expect(uploadedConfig.prerender).to.equal(true);
1156
1115
  expect(uploadedConfig.url).to.equal('https://example.com/page1');
1157
1116
 
1158
- // Verify CDN was invalidated
1117
+ // Verify CDN was invalidated using batch method with new options signature
1159
1118
  expect(client.invalidateCdnCache).to.have.been.calledOnce;
1160
- expect(client.invalidateCdnCache).to.have.been.calledWith(
1161
- 'https://example.com/page1',
1162
- 'cloudfront',
1163
- );
1119
+ const invalidateCall = client.invalidateCdnCache.firstCall.args[0];
1120
+ expect(invalidateCall).to.deep.include({
1121
+ urls: ['https://example.com/page1'],
1122
+ });
1164
1123
  });
1165
1124
 
1166
1125
  it('should throw error for unsupported opportunity type', async () => {
@@ -1254,12 +1213,12 @@ describe('TokowakaClient', () => {
1254
1213
 
1255
1214
  describe('rollbackSuggestions', () => {
1256
1215
  beforeEach(() => {
1257
- // Stub CDN invalidation for rollback tests
1258
- sinon.stub(client, 'invalidateCdnCache').resolves({
1216
+ // Stub CDN invalidation for rollback tests (now handles both single and batch)
1217
+ sinon.stub(client, 'invalidateCdnCache').resolves([{
1259
1218
  status: 'success',
1260
1219
  provider: 'cloudfront',
1261
1220
  invalidationId: 'I123',
1262
- });
1221
+ }]);
1263
1222
  });
1264
1223
 
1265
1224
  it('should rollback suggestions successfully', async () => {
@@ -1375,12 +1334,12 @@ describe('TokowakaClient', () => {
1375
1334
  expect(uploadedConfig.patches).to.have.length(1);
1376
1335
  expect(uploadedConfig.patches[0].suggestionId).to.equal('other-sugg-1');
1377
1336
 
1378
- // Verify CDN was invalidated
1337
+ // Verify CDN was invalidated using batch method with new options signature
1379
1338
  expect(client.invalidateCdnCache).to.have.been.calledOnce;
1380
- expect(client.invalidateCdnCache).to.have.been.calledWith(
1381
- 'https://example.com/page1',
1382
- 'cloudfront',
1383
- );
1339
+ const invalidateCall = client.invalidateCdnCache.firstCall.args[0];
1340
+ expect(invalidateCall).to.deep.include({
1341
+ urls: ['https://example.com/page1'],
1342
+ });
1384
1343
  });
1385
1344
 
1386
1345
  it('should handle no existing config gracefully', async () => {
@@ -1629,7 +1588,8 @@ describe('TokowakaClient', () => {
1629
1588
  );
1630
1589
 
1631
1590
  expect(result.s3Paths).to.have.length(2);
1632
- expect(result.cdnInvalidations).to.have.length(2);
1591
+ // Batch invalidation returns 1 result per CDN provider, not per URL
1592
+ expect(result.cdnInvalidations).to.have.length(1);
1633
1593
  expect(result.succeededSuggestions).to.have.length(2);
1634
1594
  });
1635
1595
 
@@ -2048,10 +2008,11 @@ describe('TokowakaClient', () => {
2048
2008
  );
2049
2009
 
2050
2010
  expect(client.invalidateCdnCache).to.have.been.calledOnce;
2051
- const { firstCall } = client.invalidateCdnCache;
2052
- expect(firstCall.args[0]).to.equal('https://example.com/page1');
2053
- expect(firstCall.args[1]).to.equal('cloudfront');
2054
- expect(firstCall.args[2]).to.be.true; // isPreview
2011
+ const invalidateCall = client.invalidateCdnCache.firstCall.args[0];
2012
+ expect(invalidateCall).to.deep.include({
2013
+ urls: ['https://example.com/page1'],
2014
+ isPreview: true,
2015
+ });
2055
2016
  });
2056
2017
 
2057
2018
  it('should throw error if suggestions span multiple URLs', async () => {
@@ -2118,9 +2079,12 @@ describe('TokowakaClient', () => {
2118
2079
  });
2119
2080
 
2120
2081
  it('should invalidate CDN cache successfully', async () => {
2121
- const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
2082
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2122
2083
 
2123
- expect(result).to.deep.equal({
2084
+ // Now returns array with one result per provider
2085
+ expect(result).to.be.an('array');
2086
+ expect(result).to.have.lengthOf(1);
2087
+ expect(result[0]).to.deep.equal({
2124
2088
  status: 'success',
2125
2089
  provider: 'cloudfront',
2126
2090
  invalidationId: 'I123',
@@ -2129,63 +2093,299 @@ describe('TokowakaClient', () => {
2129
2093
  expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
2130
2094
  '/opportunities/example.com/L3BhZ2Ux',
2131
2095
  ]);
2132
- expect(log.debug).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
2096
+ expect(log.info).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
2133
2097
  expect(log.info).to.have.been.calledWith(sinon.match(/CDN cache invalidation completed/));
2134
2098
  });
2135
2099
 
2136
2100
  it('should invalidate CDN cache for preview path', async () => {
2137
- await client.invalidateCdnCache('https://example.com/page1', 'cloudfront', true);
2101
+ await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront', isPreview: true });
2138
2102
 
2139
2103
  expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
2140
2104
  '/preview/opportunities/example.com/L3BhZ2Ux',
2141
2105
  ]);
2142
2106
  });
2143
2107
 
2144
- it('should throw error if URL is missing', async () => {
2145
- try {
2146
- await client.invalidateCdnCache('', 'cloudfront');
2147
- expect.fail('Should have thrown error');
2148
- } catch (error) {
2149
- expect(error.message).to.equal('URL and provider are required');
2150
- expect(error.status).to.equal(400);
2151
- }
2108
+ it('should return empty array if URL array is empty', async () => {
2109
+ const result = await client.invalidateCdnCache({ urls: [], providers: 'cloudfront' });
2110
+ expect(result).to.deep.equal([]);
2152
2111
  });
2153
2112
 
2154
- it('should throw error if provider is missing', async () => {
2155
- try {
2156
- await client.invalidateCdnCache('https://example.com/page1', '');
2157
- expect.fail('Should have thrown error');
2158
- } catch (error) {
2159
- expect(error.message).to.equal('URL and provider are required');
2160
- expect(error.status).to.equal(400);
2161
- }
2113
+ it('should return empty array if provider is missing', async () => {
2114
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: '' });
2115
+ expect(result).to.deep.equal([]);
2116
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2162
2117
  });
2163
2118
 
2164
2119
  it('should return error object if no CDN client available', async () => {
2165
2120
  client.cdnClientRegistry.getClient.returns(null);
2166
2121
 
2167
- const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
2122
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2168
2123
 
2169
- expect(result).to.deep.equal({
2124
+ // Now returns array with one result per provider
2125
+ expect(result).to.be.an('array');
2126
+ expect(result).to.have.lengthOf(1);
2127
+ expect(result[0]).to.deep.equal({
2170
2128
  status: 'error',
2171
2129
  provider: 'cloudfront',
2172
2130
  message: 'No CDN client available for provider: cloudfront',
2173
2131
  });
2174
- expect(log.error).to.have.been.calledWith(sinon.match(/Failed to invalidate Tokowaka CDN cache/));
2175
2132
  });
2176
2133
 
2177
2134
  it('should return error object if CDN invalidation fails', async () => {
2178
2135
  mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
2179
2136
 
2180
- const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
2137
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2181
2138
 
2182
- expect(result).to.deep.equal({
2139
+ // Now returns array with one result per provider
2140
+ expect(result).to.be.an('array');
2141
+ expect(result).to.have.lengthOf(1);
2142
+ expect(result[0]).to.deep.equal({
2183
2143
  status: 'error',
2184
2144
  provider: 'cloudfront',
2185
2145
  message: 'CDN API error',
2186
2146
  });
2187
2147
 
2188
- expect(log.error).to.have.been.calledWith(sinon.match(/Failed to invalidate Tokowaka CDN cache/));
2148
+ expect(log.warn).to.have.been.calledWith(sinon.match(/Failed to invalidate cloudfront CDN cache/));
2149
+ });
2150
+ });
2151
+
2152
+ describe('invalidateCdnCache (batch/multiple URLs)', () => {
2153
+ let mockCdnClient;
2154
+
2155
+ beforeEach(() => {
2156
+ mockCdnClient = {
2157
+ invalidateCache: sinon.stub().resolves({
2158
+ status: 'success',
2159
+ provider: 'cloudfront',
2160
+ invalidationId: 'I123',
2161
+ }),
2162
+ };
2163
+
2164
+ sinon.stub(client.cdnClientRegistry, 'getClient').returns(mockCdnClient);
2165
+ });
2166
+
2167
+ it('should invalidate CDN cache for multiple URLs (batch)', async () => {
2168
+ const urls = [
2169
+ 'https://example.com/page1',
2170
+ 'https://example.com/page2',
2171
+ 'https://example.com/page3',
2172
+ ];
2173
+
2174
+ // Pass array of URLs for batch invalidation
2175
+ const result = await client.invalidateCdnCache({ urls, providers: 'cloudfront' });
2176
+
2177
+ expect(result).to.be.an('array');
2178
+ expect(result).to.have.lengthOf(1);
2179
+ expect(result[0]).to.deep.equal({
2180
+ status: 'success',
2181
+ provider: 'cloudfront',
2182
+ invalidationId: 'I123',
2183
+ });
2184
+
2185
+ expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
2186
+ '/opportunities/example.com/L3BhZ2Ux',
2187
+ '/opportunities/example.com/L3BhZ2Uy',
2188
+ '/opportunities/example.com/L3BhZ2Uz',
2189
+ ]);
2190
+ expect(log.info).to.have.been.calledWith(sinon.match(/Invalidating CDN cache for 3 path\(s\)/));
2191
+ });
2192
+
2193
+ it('should return empty array for empty URLs array', async () => {
2194
+ const result = await client.invalidateCdnCache({ urls: [], providers: 'cloudfront' });
2195
+ expect(result).to.deep.equal([]);
2196
+ });
2197
+
2198
+ it('should return empty array if providers is empty', async () => {
2199
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: '' });
2200
+ expect(result).to.deep.equal([]);
2201
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2202
+ });
2203
+
2204
+ it('should return error object if no CDN client available', async () => {
2205
+ client.cdnClientRegistry.getClient.returns(null);
2206
+
2207
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2208
+
2209
+ expect(result).to.be.an('array');
2210
+ expect(result).to.have.lengthOf(1);
2211
+ expect(result[0]).to.deep.equal({
2212
+ status: 'error',
2213
+ provider: 'cloudfront',
2214
+ message: 'No CDN client available for provider: cloudfront',
2215
+ });
2216
+ });
2217
+
2218
+ it('should return error object if CDN invalidation fails', async () => {
2219
+ mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
2220
+
2221
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2222
+
2223
+ expect(result).to.be.an('array');
2224
+ expect(result).to.have.lengthOf(1);
2225
+ expect(result[0]).to.deep.equal({
2226
+ status: 'error',
2227
+ provider: 'cloudfront',
2228
+ message: 'CDN API error',
2229
+ });
2230
+ });
2231
+
2232
+ it('should handle multiple providers in parallel', async () => {
2233
+ const mockFastlyClient = {
2234
+ invalidateCache: sinon.stub().resolves({
2235
+ status: 'success',
2236
+ provider: 'fastly',
2237
+ purgeId: 'F456',
2238
+ }),
2239
+ };
2240
+
2241
+ client.cdnClientRegistry.getClient.withArgs('cloudfront').returns(mockCdnClient);
2242
+ client.cdnClientRegistry.getClient.withArgs('fastly').returns(mockFastlyClient);
2243
+
2244
+ const result = await client.invalidateCdnCache({
2245
+ urls: ['https://example.com/page1'],
2246
+ providers: ['cloudfront', 'fastly'],
2247
+ });
2248
+
2249
+ expect(result).to.be.an('array');
2250
+ expect(result).to.have.lengthOf(2);
2251
+ expect(result[0].provider).to.equal('cloudfront');
2252
+ expect(result[1].provider).to.equal('fastly');
2253
+
2254
+ expect(mockCdnClient.invalidateCache).to.have.been.calledOnce;
2255
+ expect(mockFastlyClient.invalidateCache).to.have.been.calledOnce;
2256
+ });
2257
+
2258
+ it('should handle errors from getClient', async () => {
2259
+ // Simulate an error when getting the CDN client
2260
+ client.cdnClientRegistry.getClient.restore();
2261
+ sinon.stub(client.cdnClientRegistry, 'getClient').throws(new Error('Unexpected error'));
2262
+
2263
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2264
+
2265
+ expect(result).to.be.an('array');
2266
+ expect(result).to.have.lengthOf(1);
2267
+ expect(result[0]).to.deep.equal({
2268
+ status: 'error',
2269
+ provider: 'cloudfront',
2270
+ message: 'Unexpected error',
2271
+ });
2272
+
2273
+ // Error is caught in provider-specific error handler
2274
+ expect(log.warn).to.have.been.calledWith(sinon.match(/Failed to invalidate cloudfront CDN cache/));
2275
+ });
2276
+
2277
+ it('should handle empty CDN provider config', async () => {
2278
+ // Test with existing client but passing no providers
2279
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: [] });
2280
+
2281
+ expect(result).to.be.an('array');
2282
+ expect(result).to.have.lengthOf(0);
2283
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2284
+ });
2285
+
2286
+ it('should handle multiple providers passed as array', async () => {
2287
+ const mockFastlyClient = {
2288
+ invalidateCache: sinon.stub().resolves({
2289
+ status: 'success',
2290
+ provider: 'fastly',
2291
+ }),
2292
+ };
2293
+
2294
+ client.cdnClientRegistry.getClient.restore();
2295
+ sinon.stub(client.cdnClientRegistry, 'getClient')
2296
+ .withArgs('cloudfront')
2297
+ .returns(mockCdnClient)
2298
+ .withArgs('fastly')
2299
+ .returns(mockFastlyClient);
2300
+
2301
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: ['cloudfront', 'fastly'] });
2302
+
2303
+ expect(result).to.be.an('array');
2304
+ expect(result).to.have.lengthOf(2);
2305
+ expect(mockCdnClient.invalidateCache).to.have.been.calledOnce;
2306
+ expect(mockFastlyClient.invalidateCache).to.have.been.calledOnce;
2307
+ });
2308
+
2309
+ it('should handle empty paths after filtering', async () => {
2310
+ // Call the method with URLs to test path generation
2311
+ const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
2312
+
2313
+ // Should successfully invalidate (paths are generated from URL)
2314
+ expect(result).to.be.an('array');
2315
+ expect(result).to.have.lengthOf(1);
2316
+ });
2317
+ });
2318
+
2319
+ describe('#getCdnProviders (edge cases)', () => {
2320
+ it('should handle missing CDN provider config (lines 105-106)', async () => {
2321
+ // Temporarily remove TOKOWAKA_CDN_PROVIDER to test early return
2322
+ const originalProvider = client.env.TOKOWAKA_CDN_PROVIDER;
2323
+ delete client.env.TOKOWAKA_CDN_PROVIDER;
2324
+
2325
+ // Call uploadMetaconfig which uses #getCdnProviders internally
2326
+ await client.uploadMetaconfig('https://example.com/page1', { siteId: 'test', prerender: true });
2327
+
2328
+ // No CDN invalidation should happen (no providers configured)
2329
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2330
+
2331
+ // Restore
2332
+ client.env.TOKOWAKA_CDN_PROVIDER = originalProvider;
2333
+ });
2334
+
2335
+ it('should handle array provider config with falsy values (line 111)', async () => {
2336
+ // Temporarily modify env to test array filtering
2337
+ const originalProvider = client.env.TOKOWAKA_CDN_PROVIDER;
2338
+ client.env.TOKOWAKA_CDN_PROVIDER = ['cloudfront', '', null, undefined]; // Array with falsy values
2339
+
2340
+ const mockCloudFrontClient = {
2341
+ invalidateCache: sinon.stub().resolves({
2342
+ status: 'success',
2343
+ provider: 'cloudfront',
2344
+ }),
2345
+ };
2346
+
2347
+ sinon.stub(client.cdnClientRegistry, 'getClient')
2348
+ .withArgs('cloudfront')
2349
+ .returns(mockCloudFrontClient);
2350
+
2351
+ // Call uploadMetaconfig which uses #getCdnProviders internally
2352
+ await client.uploadMetaconfig('https://example.com/page1', { siteId: 'test', prerender: true });
2353
+
2354
+ // Should only use 'cloudfront' (falsy values filtered out)
2355
+ expect(mockCloudFrontClient.invalidateCache).to.have.been.calledOnce;
2356
+
2357
+ // Restore
2358
+ client.env.TOKOWAKA_CDN_PROVIDER = originalProvider;
2359
+ });
2360
+
2361
+ it('should handle invalid CDN provider type - number (lines 120-121)', async () => {
2362
+ // Temporarily modify env to test invalid type
2363
+ const originalProvider = client.env.TOKOWAKA_CDN_PROVIDER;
2364
+ client.env.TOKOWAKA_CDN_PROVIDER = 12345; // Invalid type (number)
2365
+
2366
+ // Call uploadMetaconfig which uses #getCdnProviders internally
2367
+ await client.uploadMetaconfig('https://example.com/page1', { siteId: 'test', prerender: true });
2368
+
2369
+ // No CDN invalidation should happen (no providers)
2370
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2371
+
2372
+ // Restore
2373
+ client.env.TOKOWAKA_CDN_PROVIDER = originalProvider;
2374
+ });
2375
+
2376
+ it('should handle invalid CDN provider type - object (lines 120-121)', async () => {
2377
+ // Temporarily modify env to test invalid type
2378
+ const originalProvider = client.env.TOKOWAKA_CDN_PROVIDER;
2379
+ client.env.TOKOWAKA_CDN_PROVIDER = { key: 'value' }; // Invalid type (object)
2380
+
2381
+ // Call uploadMetaconfig which uses #getCdnProviders internally
2382
+ await client.uploadMetaconfig('https://example.com/page1', { siteId: 'test', prerender: true });
2383
+
2384
+ // No CDN invalidation should happen (no providers)
2385
+ expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
2386
+
2387
+ // Restore
2388
+ client.env.TOKOWAKA_CDN_PROVIDER = originalProvider;
2189
2389
  });
2190
2390
  });
2191
2391
  });
@@ -663,6 +663,36 @@ describe('GenericMapper', () => {
663
663
  expect(patch.valueFormat).to.equal('hast');
664
664
  });
665
665
 
666
+ it('should parse JSON patchValue when format is json', () => {
667
+ const jsonValue = {
668
+ title: 'Test Title',
669
+ description: 'Test description',
670
+ metadata: { key: 'value' },
671
+ };
672
+
673
+ const suggestion = {
674
+ getId: () => 'sugg-json-format',
675
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
676
+ getData: () => ({
677
+ transformRules: {
678
+ action: 'replace',
679
+ selector: '.content',
680
+ },
681
+ patchValue: JSON.stringify(jsonValue),
682
+ format: 'json',
683
+ url: 'https://example.com/page',
684
+ }),
685
+ };
686
+
687
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-json');
688
+
689
+ expect(patches.length).to.equal(1);
690
+ const patch = patches[0];
691
+
692
+ expect(patch.value).to.deep.equal(jsonValue);
693
+ expect(patch.valueFormat).to.equal('json');
694
+ });
695
+
666
696
  it('should use raw patchValue when format is not hast', () => {
667
697
  const suggestion = {
668
698
  getId: () => 'sugg-text',
@@ -686,6 +716,58 @@ describe('GenericMapper', () => {
686
716
  expect(patch.valueFormat).to.equal('text');
687
717
  });
688
718
 
719
+ it('should parse and include attrs when provided', () => {
720
+ const attrs = {
721
+ id: 'custom-id',
722
+ class: 'custom-class',
723
+ 'data-test': 'test-value',
724
+ };
725
+
726
+ const suggestion = {
727
+ getId: () => 'sugg-with-attrs',
728
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
729
+ getData: () => ({
730
+ transformRules: {
731
+ action: 'insertAfter',
732
+ selector: '#selector',
733
+ },
734
+ patchValue: 'Content with attributes',
735
+ attrs: JSON.stringify(attrs),
736
+ url: 'https://example.com/page',
737
+ }),
738
+ };
739
+
740
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-attrs');
741
+
742
+ expect(patches.length).to.equal(1);
743
+ const patch = patches[0];
744
+
745
+ expect(patch.attrs).to.deep.equal(attrs);
746
+ expect(patch.value).to.equal('Content with attributes');
747
+ });
748
+
749
+ it('should not include attrs when not provided', () => {
750
+ const suggestion = {
751
+ getId: () => 'sugg-no-attrs',
752
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
753
+ getData: () => ({
754
+ transformRules: {
755
+ action: 'insertAfter',
756
+ selector: '#selector',
757
+ },
758
+ patchValue: 'Content without attributes',
759
+ url: 'https://example.com/page',
760
+ }),
761
+ };
762
+
763
+ const patches = mapper.suggestionsToPatches('/page', [suggestion], 'opp-no-attrs');
764
+
765
+ expect(patches.length).to.equal(1);
766
+ const patch = patches[0];
767
+
768
+ expect(patch.attrs).to.be.undefined;
769
+ });
770
+
689
771
  it('should not include UI-only fields in patch', () => {
690
772
  const suggestion = {
691
773
  getId: () => 'sugg-ui',