@adobe/spacecat-shared-tokowaka-client 1.0.4 → 1.0.5

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.
@@ -58,7 +58,10 @@ describe('TokowakaClient', () => {
58
58
  getId: () => 'site-123',
59
59
  getBaseURL: () => 'https://example.com',
60
60
  getConfig: () => ({
61
- getTokowakaConfig: () => ({ apiKey: 'test-api-key-123' }),
61
+ getTokowakaConfig: () => ({
62
+ apiKey: 'test-api-key-123',
63
+ forwardedHost: 'example.com',
64
+ }),
62
65
  }),
63
66
  };
64
67
 
@@ -104,7 +107,7 @@ describe('TokowakaClient', () => {
104
107
  describe('constructor', () => {
105
108
  it('should create an instance with valid config', () => {
106
109
  expect(client).to.be.instanceOf(TokowakaClient);
107
- expect(client.bucketName).to.equal('test-bucket');
110
+ expect(client.deployBucketName).to.equal('test-bucket');
108
111
  expect(client.s3Client).to.equal(s3Client);
109
112
  });
110
113
 
@@ -1147,6 +1150,397 @@ describe('TokowakaClient', () => {
1147
1150
  });
1148
1151
  });
1149
1152
 
1153
+ describe('previewSuggestions', () => {
1154
+ let fetchStub;
1155
+
1156
+ beforeEach(() => {
1157
+ // Stub global fetch for HTML fetching
1158
+ fetchStub = sinon.stub(global, 'fetch');
1159
+ // Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
1160
+ fetchStub.resolves({
1161
+ ok: true,
1162
+ status: 200,
1163
+ statusText: 'OK',
1164
+ headers: {
1165
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
1166
+ },
1167
+ text: async () => '<html><body>Test HTML</body></html>',
1168
+ });
1169
+
1170
+ // Stub CDN invalidation for preview tests
1171
+ sinon.stub(client, 'invalidateCdnCache').resolves({
1172
+ status: 'success',
1173
+ provider: 'cloudfront',
1174
+ invalidationId: 'I123',
1175
+ });
1176
+
1177
+ // Stub fetchConfig to return null by default (no existing config)
1178
+ sinon.stub(client, 'fetchConfig').resolves(null);
1179
+
1180
+ // Add TOKOWAKA_EDGE_URL to env
1181
+ client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
1182
+ client.previewBucketName = 'test-preview-bucket';
1183
+ });
1184
+
1185
+ afterEach(() => {
1186
+ // fetchStub will be restored by global afterEach sinon.restore()
1187
+ // Just clean up env changes
1188
+ delete client.env.TOKOWAKA_EDGE_URL;
1189
+ });
1190
+
1191
+ it('should preview suggestions successfully with HTML', async () => {
1192
+ const result = await client.previewSuggestions(
1193
+ mockSite,
1194
+ mockOpportunity,
1195
+ mockSuggestions,
1196
+ { warmupDelayMs: 0 },
1197
+ );
1198
+
1199
+ expect(result).to.have.property('s3Path', 'preview/opportunities/test-api-key-123');
1200
+ expect(result).to.have.property('succeededSuggestions');
1201
+ expect(result.succeededSuggestions).to.have.length(2);
1202
+ expect(result).to.have.property('failedSuggestions');
1203
+ expect(result.failedSuggestions).to.have.length(0);
1204
+ expect(result).to.have.property('html');
1205
+ expect(result.html).to.have.property('url', 'https://example.com/page1');
1206
+ expect(result.html).to.have.property('originalHtml');
1207
+ expect(result.html).to.have.property('optimizedHtml');
1208
+ expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
1209
+ expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
1210
+
1211
+ // Verify fetch was called for HTML fetching
1212
+ // (4 times: warmup + actual for original and optimized)
1213
+ expect(fetchStub.callCount).to.equal(4);
1214
+ expect(s3Client.send).to.have.been.calledOnce;
1215
+ });
1216
+
1217
+ it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
1218
+ delete client.env.TOKOWAKA_EDGE_URL;
1219
+
1220
+ try {
1221
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1222
+ expect.fail('Should have thrown error');
1223
+ } catch (error) {
1224
+ expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
1225
+ expect(error.status).to.equal(500);
1226
+ }
1227
+ });
1228
+
1229
+ it('should throw error if site does not have Tokowaka API key', async () => {
1230
+ mockSite.getConfig = () => ({
1231
+ getTokowakaConfig: () => ({ forwardedHost: 'example.com' }),
1232
+ });
1233
+
1234
+ try {
1235
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1236
+ expect.fail('Should have thrown error');
1237
+ } catch (error) {
1238
+ expect(error.message).to.include('Tokowaka API key or forwarded host configured');
1239
+ expect(error.status).to.equal(400);
1240
+ }
1241
+ });
1242
+
1243
+ it('should throw error if site getConfig returns null', async () => {
1244
+ mockSite.getConfig = () => null;
1245
+
1246
+ try {
1247
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1248
+ expect.fail('Should have thrown error');
1249
+ } catch (error) {
1250
+ expect(error.message).to.include('Tokowaka API key or forwarded host configured');
1251
+ expect(error.status).to.equal(400);
1252
+ }
1253
+ });
1254
+
1255
+ it('should throw error for unsupported opportunity type', async () => {
1256
+ mockOpportunity.getType = () => 'unsupported-type';
1257
+
1258
+ try {
1259
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1260
+ expect.fail('Should have thrown error');
1261
+ } catch (error) {
1262
+ expect(error.message).to.include('No mapper found for opportunity type');
1263
+ expect(error.status).to.equal(501);
1264
+ }
1265
+ });
1266
+
1267
+ it('should handle ineligible suggestions', async () => {
1268
+ mockSuggestions = [
1269
+ {
1270
+ getId: () => 'sugg-1',
1271
+ getData: () => ({
1272
+ url: 'https://example.com/page1',
1273
+ recommendedAction: 'New Heading',
1274
+ checkType: 'heading-missing', // Not eligible
1275
+ }),
1276
+ },
1277
+ ];
1278
+
1279
+ const result = await client.previewSuggestions(
1280
+ mockSite,
1281
+ mockOpportunity,
1282
+ mockSuggestions,
1283
+ );
1284
+
1285
+ expect(result.succeededSuggestions).to.have.length(0);
1286
+ expect(result.failedSuggestions).to.have.length(1);
1287
+ expect(result.config).to.be.null;
1288
+ });
1289
+
1290
+ it('should return early when generateConfig produces empty optimizations', async () => {
1291
+ // Use suggestions that pass eligibility but fail during config generation
1292
+ mockSuggestions = [
1293
+ {
1294
+ getId: () => 'sugg-no-url',
1295
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1296
+ getData: () => ({
1297
+ // Missing URL - will be skipped in generateConfig
1298
+ recommendedAction: 'New Heading',
1299
+ checkType: 'heading-empty',
1300
+ transformRules: {
1301
+ action: 'replace',
1302
+ selector: 'h1',
1303
+ },
1304
+ }),
1305
+ },
1306
+ ];
1307
+
1308
+ const result = await client.previewSuggestions(
1309
+ mockSite,
1310
+ mockOpportunity,
1311
+ mockSuggestions,
1312
+ { warmupDelayMs: 0 },
1313
+ );
1314
+
1315
+ expect(result.succeededSuggestions).to.have.length(0);
1316
+ expect(result.failedSuggestions).to.have.length(1);
1317
+ expect(result.config).to.be.null;
1318
+ expect(log.warn).to.have.been.calledWith('No eligible suggestions to preview');
1319
+ });
1320
+
1321
+ it('should merge with existing deployed patches for the same URL', async () => {
1322
+ // Setup existing config with deployed patches
1323
+ const existingConfig = {
1324
+ siteId: 'site-123',
1325
+ baseURL: 'https://example.com',
1326
+ version: '1.0',
1327
+ tokowakaForceFail: false,
1328
+ tokowakaOptimizations: {
1329
+ '/page1': {
1330
+ prerender: true,
1331
+ patches: [
1332
+ {
1333
+ op: 'replace',
1334
+ selector: 'title',
1335
+ value: 'Deployed Title',
1336
+ opportunityId: 'opp-456',
1337
+ suggestionId: 'sugg-deployed',
1338
+ prerenderRequired: true,
1339
+ lastUpdated: 1234567890,
1340
+ },
1341
+ ],
1342
+ },
1343
+ },
1344
+ };
1345
+
1346
+ client.fetchConfig.resolves(existingConfig);
1347
+
1348
+ const result = await client.previewSuggestions(
1349
+ mockSite,
1350
+ mockOpportunity,
1351
+ mockSuggestions,
1352
+ { warmupDelayMs: 0 },
1353
+ );
1354
+
1355
+ expect(result.succeededSuggestions).to.have.length(2);
1356
+
1357
+ // Verify config was uploaded with merged patches
1358
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
1359
+ expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(3);
1360
+
1361
+ // Should have existing deployed patch + 2 new preview patches
1362
+ const deployedPatch = uploadedConfig.tokowakaOptimizations['/page1'].patches
1363
+ .find((p) => p.suggestionId === 'sugg-deployed');
1364
+ expect(deployedPatch).to.exist;
1365
+ expect(deployedPatch.value).to.equal('Deployed Title');
1366
+ });
1367
+
1368
+ it('should handle existing config with no patches for preview URL', async () => {
1369
+ // Setup existing config with patches for a different URL
1370
+ const existingConfig = {
1371
+ siteId: 'site-123',
1372
+ baseURL: 'https://example.com',
1373
+ version: '1.0',
1374
+ tokowakaForceFail: false,
1375
+ tokowakaOptimizations: {
1376
+ '/other-page': {
1377
+ prerender: true,
1378
+ patches: [
1379
+ {
1380
+ op: 'replace',
1381
+ selector: 'title',
1382
+ value: 'Other Page Title',
1383
+ opportunityId: 'opp-999',
1384
+ suggestionId: 'sugg-other',
1385
+ prerenderRequired: true,
1386
+ lastUpdated: 1234567890,
1387
+ },
1388
+ ],
1389
+ },
1390
+ },
1391
+ };
1392
+
1393
+ client.fetchConfig.resolves(existingConfig);
1394
+
1395
+ const result = await client.previewSuggestions(
1396
+ mockSite,
1397
+ mockOpportunity,
1398
+ mockSuggestions,
1399
+ { warmupDelayMs: 0 },
1400
+ );
1401
+
1402
+ expect(result.succeededSuggestions).to.have.length(2);
1403
+ expect(log.info).to.have.been.calledWith(sinon.match(/No deployed patches found/));
1404
+ });
1405
+
1406
+ it('should throw error when HTML fetch fails', async () => {
1407
+ // Mock fetch to fail
1408
+ fetchStub.rejects(new Error('Network error'));
1409
+
1410
+ try {
1411
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1412
+ expect.fail('Should have thrown error');
1413
+ } catch (error) {
1414
+ expect(error.message).to.include('Preview failed');
1415
+ expect(error.status).to.equal(500);
1416
+ }
1417
+ });
1418
+
1419
+ it('should retry HTML fetch on failure', async () => {
1420
+ // First 2 calls fail (warmup succeeds, first actual call fails)
1421
+ // Then succeed on retry
1422
+ fetchStub.onCall(0).resolves({ // warmup original
1423
+ ok: true,
1424
+ status: 200,
1425
+ statusText: 'OK',
1426
+ headers: {
1427
+ get: () => null,
1428
+ },
1429
+ text: async () => 'warmup',
1430
+ });
1431
+ fetchStub.onCall(1).rejects(new Error('Temporary failure')); // actual original - fail
1432
+ fetchStub.onCall(2).resolves({ // retry original - success
1433
+ ok: true,
1434
+ status: 200,
1435
+ statusText: 'OK',
1436
+ headers: {
1437
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
1438
+ },
1439
+ text: async () => '<html>Original</html>',
1440
+ });
1441
+ fetchStub.onCall(3).resolves({ // warmup optimized
1442
+ ok: true,
1443
+ status: 200,
1444
+ statusText: 'OK',
1445
+ headers: {
1446
+ get: () => null,
1447
+ },
1448
+ text: async () => 'warmup',
1449
+ });
1450
+ fetchStub.onCall(4).resolves({ // actual optimized
1451
+ ok: true,
1452
+ status: 200,
1453
+ statusText: 'OK',
1454
+ headers: {
1455
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
1456
+ },
1457
+ text: async () => '<html>Optimized</html>',
1458
+ });
1459
+
1460
+ const result = await client.previewSuggestions(
1461
+ mockSite,
1462
+ mockOpportunity,
1463
+ mockSuggestions,
1464
+ { warmupDelayMs: 0, retryDelayMs: 0 },
1465
+ );
1466
+
1467
+ expect(result.html.originalHtml).to.equal('<html>Original</html>');
1468
+ expect(result.html.optimizedHtml).to.equal('<html>Optimized</html>');
1469
+ expect(fetchStub.callCount).to.be.at.least(5);
1470
+ });
1471
+
1472
+ it('should use forwardedHost from site config', async () => {
1473
+ mockSite.getConfig = () => ({
1474
+ getTokowakaConfig: () => ({
1475
+ apiKey: 'test-api-key-123',
1476
+ forwardedHost: 'custom.example.com',
1477
+ }),
1478
+ });
1479
+
1480
+ await client.previewSuggestions(
1481
+ mockSite,
1482
+ mockOpportunity,
1483
+ mockSuggestions,
1484
+ { warmupDelayMs: 0 },
1485
+ );
1486
+
1487
+ // Check that fetch was called with correct headers
1488
+ const actualCall = fetchStub.getCalls().find((call) => {
1489
+ const headers = call.args[1]?.headers;
1490
+ return headers && headers['x-forwarded-host'] === 'custom.example.com';
1491
+ });
1492
+
1493
+ expect(actualCall).to.exist;
1494
+ });
1495
+
1496
+ it('should throw error when forwardedHost is not configured', async () => {
1497
+ mockSite.getConfig = () => ({
1498
+ getTokowakaConfig: () => ({
1499
+ apiKey: 'test-api-key-123',
1500
+ // forwardedHost is missing
1501
+ }),
1502
+ });
1503
+
1504
+ try {
1505
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
1506
+ expect.fail('Should have thrown error');
1507
+ } catch (error) {
1508
+ expect(error.message).to.include('Tokowaka API key or forwarded host configured');
1509
+ expect(error.status).to.equal(400);
1510
+ }
1511
+ });
1512
+
1513
+ it('should upload config to preview S3 path', async () => {
1514
+ await client.previewSuggestions(
1515
+ mockSite,
1516
+ mockOpportunity,
1517
+ mockSuggestions,
1518
+ { warmupDelayMs: 0 },
1519
+ );
1520
+
1521
+ expect(s3Client.send).to.have.been.calledOnce;
1522
+
1523
+ const putCommand = s3Client.send.firstCall.args[0];
1524
+ expect(putCommand.input.Bucket).to.equal('test-preview-bucket');
1525
+ expect(putCommand.input.Key).to.equal('preview/opportunities/test-api-key-123');
1526
+ });
1527
+
1528
+ it('should invalidate CDN cache for preview path', async () => {
1529
+ await client.previewSuggestions(
1530
+ mockSite,
1531
+ mockOpportunity,
1532
+ mockSuggestions,
1533
+ { warmupDelayMs: 0 },
1534
+ );
1535
+
1536
+ expect(client.invalidateCdnCache).to.have.been.calledWith(
1537
+ 'test-api-key-123',
1538
+ 'cloudfront',
1539
+ true,
1540
+ );
1541
+ });
1542
+ });
1543
+
1150
1544
  describe('invalidateCdnCache', () => {
1151
1545
  let mockCdnClient;
1152
1546
 
@@ -363,7 +363,6 @@ describe('HeadingsMapper', () => {
363
363
 
364
364
  const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-999');
365
365
  expect(patches.length).to.equal(0);
366
- expect(patches.length).to.equal(0);
367
366
  });
368
367
 
369
368
  it('should return empty array for heading-h1-length without selector in transformRules', () => {
@@ -380,7 +379,6 @@ describe('HeadingsMapper', () => {
380
379
 
381
380
  const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-888');
382
381
  expect(patches.length).to.equal(0);
383
- expect(patches.length).to.equal(0);
384
382
  });
385
383
 
386
384
  it('should log warning for heading-missing-h1 with missing transformRules - validation path', () => {