@adobe/spacecat-shared-tokowaka-client 1.3.0 → 1.3.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.
@@ -1040,6 +1040,59 @@ describe('TokowakaClient', () => {
1040
1040
  // Both suggestions are in result but sugg-1 skipped deployment due to no patches
1041
1041
  expect(result.succeededSuggestions).to.have.length(2);
1042
1042
  expect(result.s3Paths).to.have.length(1); // Only one URL actually deployed
1043
+ expect(log.warn).to.have.been.calledWith('No config generated for URL: https://example.com/page1');
1044
+ });
1045
+
1046
+ it('should skip URL when config has no patches after generation', async () => {
1047
+ // Stub generateConfig to return a config with no patches (defensive check)
1048
+ const originalGenerateConfig = client.generateConfig.bind(client);
1049
+ sinon.stub(client, 'generateConfig').callsFake((url, ...args) => {
1050
+ const config = originalGenerateConfig(url, ...args);
1051
+ if (config && url === 'https://example.com/page1') {
1052
+ // Return config but with empty patches array (simulating edge case)
1053
+ return { ...config, patches: [] };
1054
+ }
1055
+ return config;
1056
+ });
1057
+
1058
+ mockSuggestions = [
1059
+ {
1060
+ getId: () => 'sugg-1',
1061
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1062
+ getData: () => ({
1063
+ url: 'https://example.com/page1',
1064
+ recommendedAction: 'New Heading',
1065
+ checkType: 'heading-empty',
1066
+ transformRules: {
1067
+ action: 'replace',
1068
+ selector: 'h1',
1069
+ },
1070
+ }),
1071
+ },
1072
+ {
1073
+ getId: () => 'sugg-2',
1074
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1075
+ getData: () => ({
1076
+ url: 'https://example.com/page2',
1077
+ recommendedAction: 'New Subtitle',
1078
+ checkType: 'heading-empty',
1079
+ transformRules: {
1080
+ action: 'replace',
1081
+ selector: 'h2',
1082
+ },
1083
+ }),
1084
+ },
1085
+ ];
1086
+
1087
+ const result = await client.deploySuggestions(
1088
+ mockSite,
1089
+ mockOpportunity,
1090
+ mockSuggestions,
1091
+ );
1092
+
1093
+ expect(result.succeededSuggestions).to.have.length(2);
1094
+ expect(result.s3Paths).to.have.length(1); // Only page2 deployed
1095
+ expect(log.warn).to.have.been.calledWith('No eligible suggestions to deploy for URL: https://example.com/page1');
1043
1096
  });
1044
1097
 
1045
1098
  it('should return early when no eligible suggestions', async () => {
@@ -1066,6 +1119,50 @@ describe('TokowakaClient', () => {
1066
1119
  expect(s3Client.send).to.not.have.been.called;
1067
1120
  });
1068
1121
 
1122
+ it('should deploy prerender-only suggestions with no patches', async () => {
1123
+ // Create prerender opportunity
1124
+ const prerenderOpportunity = {
1125
+ getId: () => 'opp-prerender-123',
1126
+ getType: () => 'prerender',
1127
+ };
1128
+
1129
+ // Create prerender suggestions with no transform rules (prerender-only)
1130
+ const prerenderSuggestions = [
1131
+ {
1132
+ getId: () => 'prerender-sugg-1',
1133
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1134
+ getData: () => ({
1135
+ url: 'https://example.com/page1',
1136
+ // No transform rules - prerender only
1137
+ }),
1138
+ },
1139
+ ];
1140
+
1141
+ const result = await client.deploySuggestions(
1142
+ mockSite,
1143
+ prerenderOpportunity,
1144
+ prerenderSuggestions,
1145
+ );
1146
+
1147
+ expect(result.succeededSuggestions).to.have.length(1);
1148
+ expect(result.failedSuggestions).to.have.length(0);
1149
+ expect(result.s3Paths).to.have.length(1);
1150
+ expect(result.cdnInvalidations).to.have.length(1);
1151
+
1152
+ // Verify uploaded config has no patches but prerender is enabled
1153
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
1154
+ expect(uploadedConfig.patches).to.have.length(0);
1155
+ expect(uploadedConfig.prerender).to.equal(true);
1156
+ expect(uploadedConfig.url).to.equal('https://example.com/page1');
1157
+
1158
+ // Verify CDN was invalidated
1159
+ expect(client.invalidateCdnCache).to.have.been.calledOnce;
1160
+ expect(client.invalidateCdnCache).to.have.been.calledWith(
1161
+ 'https://example.com/page1',
1162
+ 'cloudfront',
1163
+ );
1164
+ });
1165
+
1069
1166
  it('should throw error for unsupported opportunity type', async () => {
1070
1167
  mockOpportunity.getType = () => 'unsupported-type';
1071
1168
 
@@ -1222,6 +1319,70 @@ describe('TokowakaClient', () => {
1222
1319
  expect(uploadedConfig.patches[0].suggestionId).to.equal('sugg-3');
1223
1320
  });
1224
1321
 
1322
+ it('should rollback prerender suggestions by disabling prerender flag', async () => {
1323
+ // Create prerender opportunity
1324
+ const prerenderOpportunity = {
1325
+ getId: () => 'opp-prerender-123',
1326
+ getType: () => 'prerender',
1327
+ };
1328
+
1329
+ // Create prerender suggestions
1330
+ const prerenderSuggestions = [
1331
+ {
1332
+ getId: () => 'prerender-sugg-1',
1333
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1334
+ getData: () => ({
1335
+ url: 'https://example.com/page1',
1336
+ }),
1337
+ },
1338
+ ];
1339
+
1340
+ const existingConfig = {
1341
+ url: 'https://example.com/page1',
1342
+ version: '1.0',
1343
+ forceFail: false,
1344
+ prerender: true,
1345
+ patches: [
1346
+ {
1347
+ op: 'replace',
1348
+ selector: 'h1',
1349
+ value: 'Heading 1',
1350
+ opportunityId: 'opp-other-123',
1351
+ suggestionId: 'other-sugg-1',
1352
+ prerenderRequired: false,
1353
+ lastUpdated: 1234567890,
1354
+ },
1355
+ ],
1356
+ };
1357
+
1358
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1359
+
1360
+ const result = await client.rollbackSuggestions(
1361
+ mockSite,
1362
+ prerenderOpportunity,
1363
+ prerenderSuggestions,
1364
+ );
1365
+
1366
+ expect(result.s3Paths).to.have.length(1);
1367
+ expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
1368
+ expect(result.succeededSuggestions).to.have.length(1);
1369
+ expect(result.failedSuggestions).to.have.length(0);
1370
+ expect(result.removedPatchesCount).to.equal(1);
1371
+
1372
+ // Verify uploaded config has prerender disabled but patches intact
1373
+ const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
1374
+ expect(uploadedConfig.prerender).to.equal(false);
1375
+ expect(uploadedConfig.patches).to.have.length(1);
1376
+ expect(uploadedConfig.patches[0].suggestionId).to.equal('other-sugg-1');
1377
+
1378
+ // Verify CDN was invalidated
1379
+ expect(client.invalidateCdnCache).to.have.been.calledOnce;
1380
+ expect(client.invalidateCdnCache).to.have.been.calledWith(
1381
+ 'https://example.com/page1',
1382
+ 'cloudfront',
1383
+ );
1384
+ });
1385
+
1225
1386
  it('should handle no existing config gracefully', async () => {
1226
1387
  sinon.stub(client, 'fetchConfig').resolves(null);
1227
1388
 
@@ -1262,6 +1423,30 @@ describe('TokowakaClient', () => {
1262
1423
  expect(s3Client.send).to.not.have.been.called;
1263
1424
  });
1264
1425
 
1426
+ it('should handle missing patches property in config', async () => {
1427
+ const existingConfig = {
1428
+ url: 'https://example.com/page1',
1429
+ version: '1.0',
1430
+ forceFail: false,
1431
+ prerender: true,
1432
+ // patches property is missing
1433
+ };
1434
+
1435
+ sinon.stub(client, 'fetchConfig').resolves(existingConfig);
1436
+
1437
+ const result = await client.rollbackSuggestions(
1438
+ mockSite,
1439
+ mockOpportunity,
1440
+ mockSuggestions,
1441
+ );
1442
+
1443
+ // Code marks eligible suggestions as succeeded even if patches property missing
1444
+ expect(result.succeededSuggestions).to.have.length(2);
1445
+ expect(result.failedSuggestions).to.have.length(0);
1446
+ expect(result.s3Paths).to.have.length(0);
1447
+ expect(s3Client.send).to.not.have.been.called;
1448
+ });
1449
+
1265
1450
  it('should handle suggestions not found in config', async () => {
1266
1451
  const existingConfig = {
1267
1452
  url: 'https://example.com/page1',
@@ -1589,6 +1774,64 @@ describe('TokowakaClient', () => {
1589
1774
  expect(s3Client.send).to.have.been.calledOnce;
1590
1775
  });
1591
1776
 
1777
+ it('should preview prerender-only suggestions with no patches', async () => {
1778
+ // Update fetchConfig to return existing config with deployed patches
1779
+ client.fetchConfig.resolves({
1780
+ url: 'https://example.com/page1',
1781
+ version: '1.0',
1782
+ forceFail: false,
1783
+ prerender: false,
1784
+ patches: [
1785
+ {
1786
+ op: 'replace',
1787
+ selector: 'h1',
1788
+ value: 'Existing Heading',
1789
+ opportunityId: 'opp-other-123',
1790
+ suggestionId: 'sugg-other',
1791
+ prerenderRequired: false,
1792
+ lastUpdated: 1234567890,
1793
+ },
1794
+ ],
1795
+ });
1796
+
1797
+ // Create prerender opportunity
1798
+ const prerenderOpportunity = {
1799
+ getId: () => 'opp-prerender-123',
1800
+ getType: () => 'prerender',
1801
+ };
1802
+
1803
+ // Create prerender suggestions with no transform rules (prerender-only)
1804
+ const prerenderSuggestions = [
1805
+ {
1806
+ getId: () => 'prerender-sugg-1',
1807
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
1808
+ getData: () => ({
1809
+ url: 'https://example.com/page1',
1810
+ // No transform rules - prerender only
1811
+ }),
1812
+ },
1813
+ ];
1814
+
1815
+ const result = await client.previewSuggestions(
1816
+ mockSite,
1817
+ prerenderOpportunity,
1818
+ prerenderSuggestions,
1819
+ { warmupDelayMs: 0 },
1820
+ );
1821
+
1822
+ expect(result).to.have.property('s3Path');
1823
+ expect(result.config).to.not.be.null;
1824
+ expect(result.config.patches).to.have.length(1); // Merged with existing deployed patch
1825
+ expect(result.config.prerender).to.equal(true); // Prerender enabled
1826
+ expect(result.succeededSuggestions).to.have.length(1);
1827
+ expect(result.failedSuggestions).to.have.length(0);
1828
+ expect(result).to.have.property('html');
1829
+
1830
+ // Verify fetch was called for HTML fetching
1831
+ expect(fetchStub.callCount).to.equal(4);
1832
+ expect(s3Client.send).to.have.been.calledOnce;
1833
+ });
1834
+
1592
1835
  it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
1593
1836
  delete client.env.TOKOWAKA_EDGE_URL;
1594
1837
 
@@ -41,6 +41,12 @@ describe('GenericMapper', () => {
41
41
  });
42
42
  });
43
43
 
44
+ describe('allowConfigsWithoutPatch', () => {
45
+ it('should return false for generic mapper', () => {
46
+ expect(mapper.allowConfigsWithoutPatch()).to.be.false;
47
+ });
48
+ });
49
+
44
50
  describe('canDeploy', () => {
45
51
  it('should return eligible for valid suggestion with all required fields', () => {
46
52
  const suggestion = {
@@ -94,6 +94,24 @@ describe('HeadingsMapper', () => {
94
94
  expect(result).to.deep.equal({ eligible: true });
95
95
  });
96
96
 
97
+ it('should return eligible for heading-order-invalid checkType', () => {
98
+ const suggestion = {
99
+ getData: () => ({
100
+ checkType: 'heading-order-invalid',
101
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
102
+ transformRules: {
103
+ action: 'replaceWith',
104
+ selector: '.invalid-section',
105
+ valueFormat: 'hast',
106
+ },
107
+ }),
108
+ };
109
+
110
+ const result = mapper.canDeploy(suggestion);
111
+
112
+ expect(result).to.deep.equal({ eligible: true });
113
+ });
114
+
97
115
  it('should return ineligible for unknown checkType', () => {
98
116
  const suggestion = {
99
117
  getData: () => ({ checkType: 'unknown-type' }),
@@ -103,7 +121,7 @@ describe('HeadingsMapper', () => {
103
121
 
104
122
  expect(result).to.deep.equal({
105
123
  eligible: false,
106
- reason: 'Only heading-empty, heading-missing-h1, heading-h1-length can be deployed. This suggestion has checkType: unknown-type',
124
+ reason: 'Only heading-empty, heading-missing-h1, heading-h1-length, heading-order-invalid can be deployed. This suggestion has checkType: unknown-type',
107
125
  });
108
126
  });
109
127
 
@@ -116,7 +134,7 @@ describe('HeadingsMapper', () => {
116
134
 
117
135
  expect(result).to.deep.equal({
118
136
  eligible: false,
119
- reason: 'Only heading-empty, heading-missing-h1, heading-h1-length can be deployed. This suggestion has checkType: undefined',
137
+ reason: 'Only heading-empty, heading-missing-h1, heading-h1-length, heading-order-invalid can be deployed. This suggestion has checkType: undefined',
120
138
  });
121
139
  });
122
140
 
@@ -129,7 +147,7 @@ describe('HeadingsMapper', () => {
129
147
 
130
148
  expect(result).to.deep.equal({
131
149
  eligible: false,
132
- reason: 'Only heading-empty, heading-missing-h1, heading-h1-length can be deployed. This suggestion has checkType: undefined',
150
+ reason: 'Only heading-empty, heading-missing-h1, heading-h1-length, heading-order-invalid can be deployed. This suggestion has checkType: undefined',
133
151
  });
134
152
  });
135
153
 
@@ -231,6 +249,68 @@ describe('HeadingsMapper', () => {
231
249
  reason: 'transformRules.action must be replace for heading-h1-length',
232
250
  });
233
251
  });
252
+
253
+ it('should return ineligible for heading-order-invalid with invalid action', () => {
254
+ const suggestion = {
255
+ getData: () => ({
256
+ checkType: 'heading-order-invalid',
257
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
258
+ transformRules: {
259
+ action: 'replace',
260
+ selector: '.invalid-section',
261
+ valueFormat: 'hast',
262
+ },
263
+ }),
264
+ };
265
+
266
+ const result = mapper.canDeploy(suggestion);
267
+
268
+ expect(result).to.deep.equal({
269
+ eligible: false,
270
+ reason: 'transformRules.action must be replaceWith for heading-order-invalid',
271
+ });
272
+ });
273
+
274
+ it('should return ineligible for heading-order-invalid with missing valueFormat', () => {
275
+ const suggestion = {
276
+ getData: () => ({
277
+ checkType: 'heading-order-invalid',
278
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
279
+ transformRules: {
280
+ action: 'replaceWith',
281
+ selector: '.invalid-section',
282
+ },
283
+ }),
284
+ };
285
+
286
+ const result = mapper.canDeploy(suggestion);
287
+
288
+ expect(result).to.deep.equal({
289
+ eligible: false,
290
+ reason: 'transformRules.valueFormat must be hast for heading-order-invalid',
291
+ });
292
+ });
293
+
294
+ it('should return ineligible for heading-order-invalid with invalid valueFormat', () => {
295
+ const suggestion = {
296
+ getData: () => ({
297
+ checkType: 'heading-order-invalid',
298
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
299
+ transformRules: {
300
+ action: 'replaceWith',
301
+ selector: '.invalid-section',
302
+ valueFormat: 'text',
303
+ },
304
+ }),
305
+ };
306
+
307
+ const result = mapper.canDeploy(suggestion);
308
+
309
+ expect(result).to.deep.equal({
310
+ eligible: false,
311
+ reason: 'transformRules.valueFormat must be hast for heading-order-invalid',
312
+ });
313
+ });
234
314
  });
235
315
 
236
316
  describe('suggestionsToPatches', () => {
@@ -352,6 +432,77 @@ describe('HeadingsMapper', () => {
352
432
  expect(patch.tag).to.be.undefined;
353
433
  });
354
434
 
435
+ it('should create patch for heading-order-invalid with transformRules', () => {
436
+ const hastValue = {
437
+ type: 'element',
438
+ tagName: 'div',
439
+ children: [
440
+ { type: 'element', tagName: 'h2', children: [{ type: 'text', value: 'Section Title' }] },
441
+ { type: 'element', tagName: 'h3', children: [{ type: 'text', value: 'Subsection' }] },
442
+ ],
443
+ };
444
+
445
+ const suggestion = {
446
+ getId: () => 'sugg-101',
447
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
448
+ getData: () => ({
449
+ checkType: 'heading-order-invalid',
450
+ recommendedAction: hastValue,
451
+ transformRules: {
452
+ action: 'replaceWith',
453
+ selector: '.content-section',
454
+ valueFormat: 'hast',
455
+ },
456
+ }),
457
+ };
458
+
459
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-101');
460
+ expect(patches.length).to.equal(1);
461
+ const patch = patches[0];
462
+
463
+ expect(patch).to.deep.include({
464
+ op: 'replaceWith',
465
+ selector: '.content-section',
466
+ value: hastValue,
467
+ opportunityId: 'opp-101',
468
+ suggestionId: 'sugg-101',
469
+ prerenderRequired: true,
470
+ });
471
+ expect(patch.lastUpdated).to.be.a('number');
472
+ expect(patch.tag).to.be.undefined;
473
+ });
474
+
475
+ it('should return empty array for heading-order-invalid without transformRules', () => {
476
+ const suggestion = {
477
+ getId: () => 'sugg-102',
478
+ getData: () => ({
479
+ checkType: 'heading-order-invalid',
480
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
481
+ }),
482
+ };
483
+
484
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-102');
485
+ expect(patches.length).to.equal(0);
486
+ });
487
+
488
+ it('should return empty array for heading-order-invalid with invalid action', () => {
489
+ const suggestion = {
490
+ getId: () => 'sugg-103',
491
+ getData: () => ({
492
+ checkType: 'heading-order-invalid',
493
+ recommendedAction: { type: 'element', tagName: 'h2', children: [] },
494
+ transformRules: {
495
+ action: 'replace',
496
+ selector: '.content-section',
497
+ valueFormat: 'hast',
498
+ },
499
+ }),
500
+ };
501
+
502
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-103');
503
+ expect(patches.length).to.equal(0);
504
+ });
505
+
355
506
  it('should return empty array for heading-missing-h1 without transformRules', () => {
356
507
  const suggestion = {
357
508
  getId: () => 'sugg-999',
@@ -0,0 +1,216 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+
15
+ import { expect } from 'chai';
16
+ import PrerenderMapper from '../../src/mappers/prerender-mapper.js';
17
+
18
+ describe('PrerenderMapper', () => {
19
+ let mapper;
20
+ let log;
21
+
22
+ beforeEach(() => {
23
+ log = {
24
+ debug: () => {},
25
+ info: () => {},
26
+ warn: () => {},
27
+ error: () => {},
28
+ };
29
+ mapper = new PrerenderMapper(log);
30
+ });
31
+
32
+ describe('getOpportunityType', () => {
33
+ it('should return prerender', () => {
34
+ expect(mapper.getOpportunityType()).to.equal('prerender');
35
+ });
36
+ });
37
+
38
+ describe('requiresPrerender', () => {
39
+ it('should return true', () => {
40
+ expect(mapper.requiresPrerender()).to.be.true;
41
+ });
42
+ });
43
+
44
+ describe('allowConfigsWithoutPatch', () => {
45
+ it('should return true for prerender mapper', () => {
46
+ expect(mapper.allowConfigsWithoutPatch()).to.be.true;
47
+ });
48
+ });
49
+
50
+ describe('suggestionsToPatches', () => {
51
+ it('should return empty array for prerender suggestions', () => {
52
+ const suggestion = {
53
+ getId: () => 'test-suggestion-id',
54
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
55
+ getData: () => ({
56
+ url: 'https://example.com/page',
57
+ }),
58
+ };
59
+
60
+ const patches = mapper.suggestionsToPatches(
61
+ '/page',
62
+ [suggestion],
63
+ 'test-opportunity-id',
64
+ );
65
+
66
+ expect(patches).to.be.an('array');
67
+ expect(patches).to.be.empty;
68
+ });
69
+
70
+ it('should return empty array even with multiple suggestions', () => {
71
+ const suggestions = [
72
+ {
73
+ getId: () => 'suggestion-1',
74
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
75
+ getData: () => ({
76
+ url: 'https://example.com/page1',
77
+ }),
78
+ },
79
+ {
80
+ getId: () => 'suggestion-2',
81
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
82
+ getData: () => ({
83
+ url: 'https://example.com/page2',
84
+ }),
85
+ },
86
+ ];
87
+
88
+ const patches = mapper.suggestionsToPatches(
89
+ '/page',
90
+ suggestions,
91
+ 'test-opportunity-id',
92
+ );
93
+
94
+ expect(patches).to.be.an('array');
95
+ expect(patches).to.be.empty;
96
+ });
97
+ });
98
+
99
+ describe('canDeploy', () => {
100
+ it('should return eligible for valid suggestion with URL', () => {
101
+ const suggestion = {
102
+ getData: () => ({
103
+ url: 'https://example.com/page',
104
+ }),
105
+ };
106
+
107
+ const result = mapper.canDeploy(suggestion);
108
+
109
+ expect(result).to.deep.equal({ eligible: true });
110
+ });
111
+
112
+ it('should return ineligible when URL is missing', () => {
113
+ const suggestion = {
114
+ getData: () => ({}),
115
+ };
116
+
117
+ const result = mapper.canDeploy(suggestion);
118
+
119
+ expect(result).to.deep.equal({
120
+ eligible: false,
121
+ reason: 'url is required',
122
+ });
123
+ });
124
+
125
+ it('should return ineligible when URL is empty string', () => {
126
+ const suggestion = {
127
+ getData: () => ({
128
+ url: '',
129
+ }),
130
+ };
131
+
132
+ const result = mapper.canDeploy(suggestion);
133
+
134
+ expect(result).to.deep.equal({
135
+ eligible: false,
136
+ reason: 'url is required',
137
+ });
138
+ });
139
+
140
+ it('should return ineligible when URL is null', () => {
141
+ const suggestion = {
142
+ getData: () => ({
143
+ url: null,
144
+ }),
145
+ };
146
+
147
+ const result = mapper.canDeploy(suggestion);
148
+
149
+ expect(result).to.deep.equal({
150
+ eligible: false,
151
+ reason: 'url is required',
152
+ });
153
+ });
154
+
155
+ it('should return ineligible when URL is undefined', () => {
156
+ const suggestion = {
157
+ getData: () => ({
158
+ url: undefined,
159
+ }),
160
+ };
161
+
162
+ const result = mapper.canDeploy(suggestion);
163
+
164
+ expect(result).to.deep.equal({
165
+ eligible: false,
166
+ reason: 'url is required',
167
+ });
168
+ });
169
+
170
+ it('should return ineligible when data is null', () => {
171
+ const suggestion = {
172
+ getData: () => null,
173
+ };
174
+
175
+ const result = mapper.canDeploy(suggestion);
176
+
177
+ expect(result).to.deep.equal({
178
+ eligible: false,
179
+ reason: 'url is required',
180
+ });
181
+ });
182
+
183
+ it('should return ineligible when data is undefined', () => {
184
+ const suggestion = {
185
+ getData: () => undefined,
186
+ };
187
+
188
+ const result = mapper.canDeploy(suggestion);
189
+
190
+ expect(result).to.deep.equal({
191
+ eligible: false,
192
+ reason: 'url is required',
193
+ });
194
+ });
195
+
196
+ it('should return eligible for various valid URL formats', () => {
197
+ const urls = [
198
+ 'https://example.com',
199
+ 'https://example.com/path',
200
+ 'https://subdomain.example.com/path/to/page',
201
+ 'https://example.com/path?query=value',
202
+ 'https://example.com/path#hash',
203
+ ];
204
+
205
+ urls.forEach((url) => {
206
+ const suggestion = {
207
+ getData: () => ({ url }),
208
+ };
209
+
210
+ const result = mapper.canDeploy(suggestion);
211
+
212
+ expect(result).to.deep.equal({ eligible: true });
213
+ });
214
+ });
215
+ });
216
+ });