@adobe/spacecat-shared-tokowaka-client 1.1.1 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.releaserc.cjs +17 -0
- package/CHANGELOG.md +69 -1
- package/CODE_OF_CONDUCT.md +75 -0
- package/CONTRIBUTING.md +74 -0
- package/README.md +155 -15
- package/package.json +4 -4
- package/src/index.d.ts +120 -25
- package/src/index.js +481 -177
- package/src/mappers/base-mapper.js +41 -9
- package/src/mappers/content-summarization-mapper.js +38 -35
- package/src/mappers/faq-mapper.js +247 -0
- package/src/mappers/headings-mapper.js +37 -23
- package/src/mappers/mapper-registry.js +2 -0
- package/src/utils/custom-html-utils.js +195 -0
- package/src/utils/markdown-utils.js +24 -0
- package/src/utils/patch-utils.js +103 -0
- package/src/utils/s3-utils.js +117 -0
- package/src/utils/site-utils.js +25 -0
- package/src/utils/suggestion-utils.js +69 -0
- package/test/index.test.js +1268 -462
- package/test/mappers/base-mapper.test.js +250 -7
- package/test/mappers/content-mapper.test.js +26 -24
- package/test/mappers/faq-mapper.test.js +1428 -0
- package/test/mappers/headings-mapper.test.js +23 -17
- package/test/utils/html-utils.test.js +432 -0
- package/test/utils/patch-utils.test.js +409 -0
- package/test/utils/s3-utils.test.js +140 -0
- package/test/utils/site-utils.test.js +80 -0
- package/test/utils/suggestion-utils.test.js +187 -0
package/test/index.test.js
CHANGED
|
@@ -50,7 +50,12 @@ describe('TokowakaClient', () => {
|
|
|
50
50
|
};
|
|
51
51
|
|
|
52
52
|
client = new TokowakaClient(
|
|
53
|
-
{
|
|
53
|
+
{
|
|
54
|
+
bucketName: 'test-bucket',
|
|
55
|
+
previewBucketName: 'test-preview-bucket',
|
|
56
|
+
s3Client,
|
|
57
|
+
env,
|
|
58
|
+
},
|
|
54
59
|
log,
|
|
55
60
|
);
|
|
56
61
|
|
|
@@ -58,7 +63,10 @@ describe('TokowakaClient', () => {
|
|
|
58
63
|
getId: () => 'site-123',
|
|
59
64
|
getBaseURL: () => 'https://example.com',
|
|
60
65
|
getConfig: () => ({
|
|
61
|
-
getTokowakaConfig: () => ({
|
|
66
|
+
getTokowakaConfig: () => ({
|
|
67
|
+
forwardedHost: 'example.com',
|
|
68
|
+
apiKey: 'test-api-key',
|
|
69
|
+
}),
|
|
62
70
|
}),
|
|
63
71
|
};
|
|
64
72
|
|
|
@@ -104,7 +112,8 @@ describe('TokowakaClient', () => {
|
|
|
104
112
|
describe('constructor', () => {
|
|
105
113
|
it('should create an instance with valid config', () => {
|
|
106
114
|
expect(client).to.be.instanceOf(TokowakaClient);
|
|
107
|
-
expect(client.
|
|
115
|
+
expect(client.deployBucketName).to.equal('test-bucket');
|
|
116
|
+
expect(client.previewBucketName).to.equal('test-preview-bucket');
|
|
108
117
|
expect(client.s3Client).to.equal(s3Client);
|
|
109
118
|
});
|
|
110
119
|
|
|
@@ -117,12 +126,24 @@ describe('TokowakaClient', () => {
|
|
|
117
126
|
expect(() => new TokowakaClient({ bucketName: 'test-bucket' }, log))
|
|
118
127
|
.to.throw('S3 client is required');
|
|
119
128
|
});
|
|
129
|
+
|
|
130
|
+
it('should use deployBucketName for preview if previewBucketName not provided', () => {
|
|
131
|
+
const clientWithoutPreview = new TokowakaClient(
|
|
132
|
+
{ bucketName: 'test-bucket', s3Client },
|
|
133
|
+
log,
|
|
134
|
+
);
|
|
135
|
+
// previewBucketName is undefined if not explicitly provided
|
|
136
|
+
expect(clientWithoutPreview.previewBucketName).to.be.undefined;
|
|
137
|
+
});
|
|
120
138
|
});
|
|
121
139
|
|
|
122
140
|
describe('createFrom', () => {
|
|
123
141
|
it('should create client from context', () => {
|
|
124
142
|
const context = {
|
|
125
|
-
env: {
|
|
143
|
+
env: {
|
|
144
|
+
TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket',
|
|
145
|
+
TOKOWAKA_PREVIEW_BUCKET: 'test-preview-bucket',
|
|
146
|
+
},
|
|
126
147
|
s3: { s3Client },
|
|
127
148
|
log,
|
|
128
149
|
};
|
|
@@ -131,6 +152,7 @@ describe('TokowakaClient', () => {
|
|
|
131
152
|
|
|
132
153
|
expect(createdClient).to.be.instanceOf(TokowakaClient);
|
|
133
154
|
expect(context.tokowakaClient).to.equal(createdClient);
|
|
155
|
+
expect(createdClient.previewBucketName).to.equal('test-preview-bucket');
|
|
134
156
|
});
|
|
135
157
|
|
|
136
158
|
it('should reuse existing client from context', () => {
|
|
@@ -174,13 +196,8 @@ describe('TokowakaClient', () => {
|
|
|
174
196
|
}
|
|
175
197
|
|
|
176
198
|
// eslint-disable-next-line class-methods-use-this
|
|
177
|
-
|
|
178
|
-
return
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// eslint-disable-next-line class-methods-use-this
|
|
182
|
-
validateSuggestionData() {
|
|
183
|
-
return true;
|
|
199
|
+
suggestionsToPatches() {
|
|
200
|
+
return [];
|
|
184
201
|
}
|
|
185
202
|
|
|
186
203
|
// eslint-disable-next-line class-methods-use-this
|
|
@@ -198,214 +215,335 @@ describe('TokowakaClient', () => {
|
|
|
198
215
|
});
|
|
199
216
|
|
|
200
217
|
describe('generateConfig', () => {
|
|
201
|
-
it('should generate config for headings opportunity', () => {
|
|
202
|
-
const
|
|
218
|
+
it('should generate config for headings opportunity with single URL', () => {
|
|
219
|
+
const url = 'https://example.com/page1';
|
|
220
|
+
const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
|
|
203
221
|
|
|
204
222
|
expect(config).to.deep.include({
|
|
205
|
-
|
|
206
|
-
baseURL: 'https://example.com',
|
|
223
|
+
url: 'https://example.com/page1',
|
|
207
224
|
version: '1.0',
|
|
208
|
-
|
|
225
|
+
forceFail: false,
|
|
226
|
+
prerender: true,
|
|
209
227
|
});
|
|
210
228
|
|
|
211
|
-
expect(config.
|
|
212
|
-
expect(config.tokowakaOptimizations['/page1'].prerender).to.be.true;
|
|
213
|
-
expect(config.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
229
|
+
expect(config.patches).to.have.length(2);
|
|
214
230
|
|
|
215
|
-
const patch = config.
|
|
231
|
+
const patch = config.patches[0];
|
|
216
232
|
expect(patch).to.include({
|
|
217
233
|
op: 'replace',
|
|
218
234
|
selector: 'h1',
|
|
219
235
|
value: 'New Heading',
|
|
220
236
|
opportunityId: 'opp-123',
|
|
221
|
-
suggestionId: 'sugg-1',
|
|
222
237
|
prerenderRequired: true,
|
|
223
238
|
});
|
|
239
|
+
expect(patch.suggestionId).to.equal('sugg-1');
|
|
224
240
|
expect(patch).to.have.property('lastUpdated');
|
|
225
241
|
});
|
|
226
242
|
|
|
227
|
-
it('should
|
|
243
|
+
it('should generate config for FAQ opportunity', () => {
|
|
244
|
+
mockOpportunity = {
|
|
245
|
+
getId: () => 'opp-faq-123',
|
|
246
|
+
getType: () => 'faq',
|
|
247
|
+
};
|
|
248
|
+
|
|
228
249
|
mockSuggestions = [
|
|
229
250
|
{
|
|
230
|
-
getId: () => 'sugg-1',
|
|
251
|
+
getId: () => 'sugg-faq-1',
|
|
231
252
|
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
232
253
|
getData: () => ({
|
|
233
254
|
url: 'https://example.com/page1',
|
|
234
|
-
|
|
235
|
-
|
|
255
|
+
headingText: 'FAQs',
|
|
256
|
+
shouldOptimize: true,
|
|
257
|
+
item: {
|
|
258
|
+
question: 'Question 1?',
|
|
259
|
+
answer: 'Answer 1.',
|
|
260
|
+
},
|
|
236
261
|
transformRules: {
|
|
237
|
-
action: '
|
|
238
|
-
selector: '
|
|
262
|
+
action: 'appendChild',
|
|
263
|
+
selector: 'main',
|
|
239
264
|
},
|
|
240
265
|
}),
|
|
241
266
|
},
|
|
242
267
|
{
|
|
243
|
-
getId: () => 'sugg-2',
|
|
244
|
-
getUpdatedAt: () => '2025-01-
|
|
268
|
+
getId: () => 'sugg-faq-2',
|
|
269
|
+
getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
|
|
245
270
|
getData: () => ({
|
|
246
|
-
url: 'https://example.com/
|
|
247
|
-
|
|
248
|
-
|
|
271
|
+
url: 'https://example.com/page1',
|
|
272
|
+
headingText: 'FAQs',
|
|
273
|
+
shouldOptimize: true,
|
|
274
|
+
item: {
|
|
275
|
+
question: 'Question 2?',
|
|
276
|
+
answer: 'Answer 2.',
|
|
277
|
+
},
|
|
249
278
|
transformRules: {
|
|
250
|
-
action: '
|
|
251
|
-
selector: '
|
|
279
|
+
action: 'appendChild',
|
|
280
|
+
selector: 'main',
|
|
252
281
|
},
|
|
253
282
|
}),
|
|
254
283
|
},
|
|
255
284
|
];
|
|
256
285
|
|
|
257
|
-
const
|
|
286
|
+
const url = 'https://example.com/page1';
|
|
287
|
+
const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
|
|
258
288
|
|
|
259
|
-
expect(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
289
|
+
expect(config).to.deep.include({
|
|
290
|
+
url: 'https://example.com/page1',
|
|
291
|
+
version: '1.0',
|
|
292
|
+
forceFail: false,
|
|
293
|
+
prerender: true,
|
|
294
|
+
});
|
|
263
295
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
}),
|
|
296
|
+
expect(config.patches).to.have.length(3); // heading + 2 FAQs
|
|
297
|
+
|
|
298
|
+
// First patch: heading (no suggestionId)
|
|
299
|
+
const headingPatch = config.patches[0];
|
|
300
|
+
expect(headingPatch).to.include({
|
|
301
|
+
op: 'appendChild',
|
|
302
|
+
selector: 'main',
|
|
303
|
+
opportunityId: 'opp-faq-123',
|
|
304
|
+
prerenderRequired: true,
|
|
274
305
|
});
|
|
306
|
+
expect(headingPatch.suggestionId).to.be.undefined;
|
|
307
|
+
expect(headingPatch).to.have.property('lastUpdated');
|
|
308
|
+
expect(headingPatch.value.tagName).to.equal('h2');
|
|
309
|
+
|
|
310
|
+
// Second patch: first FAQ
|
|
311
|
+
const firstFaqPatch = config.patches[1];
|
|
312
|
+
expect(firstFaqPatch).to.include({
|
|
313
|
+
op: 'appendChild',
|
|
314
|
+
selector: 'main',
|
|
315
|
+
opportunityId: 'opp-faq-123',
|
|
316
|
+
prerenderRequired: true,
|
|
317
|
+
});
|
|
318
|
+
expect(firstFaqPatch.suggestionId).to.equal('sugg-faq-1');
|
|
319
|
+
expect(firstFaqPatch.value.tagName).to.equal('div');
|
|
320
|
+
});
|
|
275
321
|
|
|
322
|
+
it('should return null if no eligible suggestions', () => {
|
|
276
323
|
mockSuggestions = [
|
|
277
324
|
{
|
|
278
|
-
getId: () => 'sugg-
|
|
279
|
-
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
325
|
+
getId: () => 'sugg-1',
|
|
280
326
|
getData: () => ({
|
|
281
|
-
url: '/
|
|
282
|
-
|
|
283
|
-
checkType: 'heading-empty',
|
|
284
|
-
transformRules: {
|
|
285
|
-
action: 'replace',
|
|
286
|
-
selector: 'h1',
|
|
287
|
-
},
|
|
327
|
+
url: 'https://example.com/page1',
|
|
328
|
+
// Missing required fields
|
|
288
329
|
}),
|
|
289
330
|
},
|
|
290
331
|
];
|
|
291
332
|
|
|
292
|
-
const
|
|
333
|
+
const url = 'https://example.com/page1';
|
|
334
|
+
const config = client.generateConfig(url, mockOpportunity, mockSuggestions);
|
|
293
335
|
|
|
294
|
-
expect(
|
|
295
|
-
expect(config.tokowakaOptimizations).to.have.property('/relative-path');
|
|
336
|
+
expect(config).to.be.null;
|
|
296
337
|
});
|
|
297
338
|
|
|
298
|
-
it('should
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
339
|
+
it('should handle unsupported opportunity types', () => {
|
|
340
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
341
|
+
|
|
342
|
+
expect(() => client.generateConfig('https://example.com/page1', mockOpportunity, mockSuggestions))
|
|
343
|
+
.to.throw(/No mapper found for opportunity type: unsupported-type/)
|
|
344
|
+
.with.property('status', 501);
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe('fetchMetaconfig', () => {
|
|
349
|
+
it('should fetch metaconfig from S3', async () => {
|
|
350
|
+
const metaconfig = {
|
|
351
|
+
siteId: 'site-123',
|
|
352
|
+
prerender: true,
|
|
353
|
+
};
|
|
354
|
+
|
|
355
|
+
s3Client.send.resolves({
|
|
356
|
+
Body: {
|
|
357
|
+
transformToString: async () => JSON.stringify(metaconfig),
|
|
306
358
|
},
|
|
307
|
-
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
const result = await client.fetchMetaconfig('https://example.com/page1');
|
|
308
362
|
|
|
309
|
-
|
|
363
|
+
expect(result).to.deep.equal(metaconfig);
|
|
364
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
310
365
|
|
|
311
|
-
|
|
312
|
-
expect(
|
|
366
|
+
const command = s3Client.send.firstCall.args[0];
|
|
367
|
+
expect(command.input.Bucket).to.equal('test-bucket');
|
|
368
|
+
expect(command.input.Key).to.equal('opportunities/example.com/config');
|
|
313
369
|
});
|
|
314
370
|
|
|
315
|
-
it('should
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
transformRules: {
|
|
325
|
-
action: 'replace',
|
|
326
|
-
selector: 'h1',
|
|
327
|
-
},
|
|
328
|
-
}),
|
|
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),
|
|
329
380
|
},
|
|
330
|
-
|
|
381
|
+
});
|
|
331
382
|
|
|
332
|
-
|
|
383
|
+
await client.fetchMetaconfig('https://example.com/page1', true);
|
|
333
384
|
|
|
334
|
-
|
|
335
|
-
expect(
|
|
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');
|
|
336
388
|
});
|
|
337
389
|
|
|
338
|
-
it('should
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
getData: () => ({
|
|
343
|
-
url: 'https://example.com/page1',
|
|
344
|
-
// Missing required fields
|
|
345
|
-
}),
|
|
346
|
-
},
|
|
347
|
-
];
|
|
390
|
+
it('should return null if metaconfig does not exist', async () => {
|
|
391
|
+
const noSuchKeyError = new Error('NoSuchKey');
|
|
392
|
+
noSuchKeyError.name = 'NoSuchKey';
|
|
393
|
+
s3Client.send.rejects(noSuchKeyError);
|
|
348
394
|
|
|
349
|
-
const
|
|
395
|
+
const result = await client.fetchMetaconfig('https://example.com/page1');
|
|
350
396
|
|
|
351
|
-
expect(
|
|
352
|
-
expect(log.warn).to.have.been.calledWith(sinon.match(/cannot be deployed/));
|
|
397
|
+
expect(result).to.be.null;
|
|
353
398
|
});
|
|
354
399
|
|
|
355
|
-
it('should
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
getId: () => 'sugg-1',
|
|
360
|
-
getData: () => ({
|
|
361
|
-
url: 'https://example.com/page1',
|
|
362
|
-
}),
|
|
363
|
-
},
|
|
364
|
-
];
|
|
400
|
+
it('should throw error on S3 fetch failure', async () => {
|
|
401
|
+
const s3Error = new Error('Access Denied');
|
|
402
|
+
s3Error.name = 'AccessDenied';
|
|
403
|
+
s3Client.send.rejects(s3Error);
|
|
365
404
|
|
|
366
|
-
|
|
367
|
-
.
|
|
368
|
-
.
|
|
405
|
+
try {
|
|
406
|
+
await client.fetchMetaconfig('https://example.com/page1');
|
|
407
|
+
expect.fail('Should have thrown error');
|
|
408
|
+
} catch (error) {
|
|
409
|
+
expect(error.message).to.include('S3 fetch failed');
|
|
410
|
+
expect(error.status).to.equal(500);
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('should throw error if URL is missing', async () => {
|
|
415
|
+
try {
|
|
416
|
+
await client.fetchMetaconfig('');
|
|
417
|
+
expect.fail('Should have thrown error');
|
|
418
|
+
} catch (error) {
|
|
419
|
+
expect(error.message).to.include('URL is required');
|
|
420
|
+
expect(error.status).to.equal(400);
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
});
|
|
424
|
+
|
|
425
|
+
describe('uploadMetaconfig', () => {
|
|
426
|
+
it('should upload metaconfig to S3', async () => {
|
|
427
|
+
const metaconfig = {
|
|
428
|
+
siteId: 'site-123',
|
|
429
|
+
prerender: true,
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const s3Path = await client.uploadMetaconfig('https://example.com/page1', metaconfig);
|
|
433
|
+
|
|
434
|
+
expect(s3Path).to.equal('opportunities/example.com/config');
|
|
435
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
436
|
+
|
|
437
|
+
const command = s3Client.send.firstCall.args[0];
|
|
438
|
+
expect(command.input.Bucket).to.equal('test-bucket');
|
|
439
|
+
expect(command.input.Key).to.equal('opportunities/example.com/config');
|
|
440
|
+
expect(command.input.ContentType).to.equal('application/json');
|
|
441
|
+
expect(JSON.parse(command.input.Body)).to.deep.equal(metaconfig);
|
|
442
|
+
});
|
|
443
|
+
|
|
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
|
+
it('should throw error if URL is missing', async () => {
|
|
459
|
+
try {
|
|
460
|
+
await client.uploadMetaconfig('', { siteId: 'site-123', prerender: true });
|
|
461
|
+
expect.fail('Should have thrown error');
|
|
462
|
+
} catch (error) {
|
|
463
|
+
expect(error.message).to.include('URL is required');
|
|
464
|
+
expect(error.status).to.equal(400);
|
|
465
|
+
}
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it('should throw error if metaconfig is empty', async () => {
|
|
469
|
+
try {
|
|
470
|
+
await client.uploadMetaconfig('https://example.com/page1', {});
|
|
471
|
+
expect.fail('Should have thrown error');
|
|
472
|
+
} catch (error) {
|
|
473
|
+
expect(error.message).to.include('Metaconfig object is required');
|
|
474
|
+
expect(error.status).to.equal(400);
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('should throw error on S3 upload failure', async () => {
|
|
479
|
+
const s3Error = new Error('Access Denied');
|
|
480
|
+
s3Client.send.rejects(s3Error);
|
|
481
|
+
|
|
482
|
+
try {
|
|
483
|
+
await client.uploadMetaconfig('https://example.com/page1', { siteId: 'site-123', prerender: true });
|
|
484
|
+
expect.fail('Should have thrown error');
|
|
485
|
+
} catch (error) {
|
|
486
|
+
expect(error.message).to.include('S3 upload failed');
|
|
487
|
+
expect(error.status).to.equal(500);
|
|
488
|
+
}
|
|
369
489
|
});
|
|
370
490
|
});
|
|
371
491
|
|
|
372
492
|
describe('uploadConfig', () => {
|
|
373
493
|
it('should upload config to S3', async () => {
|
|
374
494
|
const config = {
|
|
375
|
-
|
|
376
|
-
baseURL: 'https://example.com',
|
|
495
|
+
url: 'https://example.com/page1',
|
|
377
496
|
version: '1.0',
|
|
378
|
-
|
|
379
|
-
|
|
497
|
+
forceFail: false,
|
|
498
|
+
prerender: true,
|
|
499
|
+
patches: [],
|
|
380
500
|
};
|
|
381
501
|
|
|
382
|
-
const s3Key = await client.uploadConfig('
|
|
502
|
+
const s3Key = await client.uploadConfig('https://example.com/page1', config);
|
|
383
503
|
|
|
384
|
-
expect(s3Key).to.equal('opportunities/
|
|
504
|
+
expect(s3Key).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
385
505
|
expect(s3Client.send).to.have.been.calledOnce;
|
|
386
506
|
|
|
387
507
|
const command = s3Client.send.firstCall.args[0];
|
|
388
508
|
expect(command.input.Bucket).to.equal('test-bucket');
|
|
389
|
-
expect(command.input.Key).to.equal('opportunities/
|
|
509
|
+
expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
390
510
|
expect(command.input.ContentType).to.equal('application/json');
|
|
391
511
|
expect(JSON.parse(command.input.Body)).to.deep.equal(config);
|
|
392
512
|
});
|
|
393
513
|
|
|
394
|
-
it('should
|
|
395
|
-
const config = {
|
|
514
|
+
it('should upload config to preview bucket', async () => {
|
|
515
|
+
const config = {
|
|
516
|
+
url: 'https://example.com/page1',
|
|
517
|
+
version: '1.0',
|
|
518
|
+
forceFail: false,
|
|
519
|
+
prerender: true,
|
|
520
|
+
patches: [],
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
const s3Key = await client.uploadConfig('https://example.com/page1', config, true);
|
|
524
|
+
|
|
525
|
+
expect(s3Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
|
|
526
|
+
|
|
527
|
+
const command = s3Client.send.firstCall.args[0];
|
|
528
|
+
expect(command.input.Bucket).to.equal('test-preview-bucket');
|
|
529
|
+
expect(command.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
it('should throw error if URL is missing', async () => {
|
|
533
|
+
const config = { url: 'https://example.com/page1', patches: [] };
|
|
396
534
|
|
|
397
535
|
try {
|
|
398
536
|
await client.uploadConfig('', config);
|
|
399
537
|
expect.fail('Should have thrown error');
|
|
400
538
|
} catch (error) {
|
|
401
|
-
expect(error.message).to.include('
|
|
539
|
+
expect(error.message).to.include('URL is required');
|
|
402
540
|
expect(error.status).to.equal(400);
|
|
403
541
|
}
|
|
404
542
|
});
|
|
405
543
|
|
|
406
544
|
it('should throw error if config is empty', async () => {
|
|
407
545
|
try {
|
|
408
|
-
await client.uploadConfig('
|
|
546
|
+
await client.uploadConfig('https://example.com/page1', {});
|
|
409
547
|
expect.fail('Should have thrown error');
|
|
410
548
|
} catch (error) {
|
|
411
549
|
expect(error.message).to.include('Config object is required');
|
|
@@ -415,10 +553,10 @@ describe('TokowakaClient', () => {
|
|
|
415
553
|
|
|
416
554
|
it('should handle S3 upload failure', async () => {
|
|
417
555
|
s3Client.send.rejects(new Error('Network error'));
|
|
418
|
-
const config = {
|
|
556
|
+
const config = { url: 'https://example.com/page1', patches: [] };
|
|
419
557
|
|
|
420
558
|
try {
|
|
421
|
-
await client.uploadConfig('
|
|
559
|
+
await client.uploadConfig('https://example.com/page1', config);
|
|
422
560
|
expect.fail('Should have thrown error');
|
|
423
561
|
} catch (error) {
|
|
424
562
|
expect(error.message).to.include('S3 upload failed');
|
|
@@ -430,26 +568,21 @@ describe('TokowakaClient', () => {
|
|
|
430
568
|
describe('fetchConfig', () => {
|
|
431
569
|
it('should fetch existing config from S3', async () => {
|
|
432
570
|
const existingConfig = {
|
|
433
|
-
|
|
434
|
-
baseURL: 'https://example.com',
|
|
571
|
+
url: 'https://example.com/page1',
|
|
435
572
|
version: '1.0',
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
prerenderRequired: true,
|
|
448
|
-
lastUpdated: 1234567890,
|
|
449
|
-
},
|
|
450
|
-
],
|
|
573
|
+
forceFail: false,
|
|
574
|
+
prerender: true,
|
|
575
|
+
patches: [
|
|
576
|
+
{
|
|
577
|
+
op: 'replace',
|
|
578
|
+
selector: 'h1',
|
|
579
|
+
value: 'Old Heading',
|
|
580
|
+
opportunityId: 'opp-123',
|
|
581
|
+
suggestionId: 'sugg-1',
|
|
582
|
+
prerenderRequired: true,
|
|
583
|
+
lastUpdated: 1234567890,
|
|
451
584
|
},
|
|
452
|
-
|
|
585
|
+
],
|
|
453
586
|
};
|
|
454
587
|
|
|
455
588
|
s3Client.send.resolves({
|
|
@@ -458,14 +591,36 @@ describe('TokowakaClient', () => {
|
|
|
458
591
|
},
|
|
459
592
|
});
|
|
460
593
|
|
|
461
|
-
const config = await client.fetchConfig('
|
|
594
|
+
const config = await client.fetchConfig('https://example.com/page1');
|
|
462
595
|
|
|
463
596
|
expect(config).to.deep.equal(existingConfig);
|
|
464
597
|
expect(s3Client.send).to.have.been.calledOnce;
|
|
465
598
|
|
|
466
599
|
const command = s3Client.send.firstCall.args[0];
|
|
467
600
|
expect(command.input.Bucket).to.equal('test-bucket');
|
|
468
|
-
expect(command.input.Key).to.equal('opportunities/
|
|
601
|
+
expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it('should fetch config from preview bucket', async () => {
|
|
605
|
+
const existingConfig = {
|
|
606
|
+
url: 'https://example.com/page1',
|
|
607
|
+
version: '1.0',
|
|
608
|
+
forceFail: false,
|
|
609
|
+
prerender: true,
|
|
610
|
+
patches: [],
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
s3Client.send.resolves({
|
|
614
|
+
Body: {
|
|
615
|
+
transformToString: async () => JSON.stringify(existingConfig),
|
|
616
|
+
},
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
await client.fetchConfig('https://example.com/page1', true);
|
|
620
|
+
|
|
621
|
+
const command = s3Client.send.firstCall.args[0];
|
|
622
|
+
expect(command.input.Bucket).to.equal('test-preview-bucket');
|
|
623
|
+
expect(command.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
|
|
469
624
|
});
|
|
470
625
|
|
|
471
626
|
it('should return null if config does not exist', async () => {
|
|
@@ -473,7 +628,7 @@ describe('TokowakaClient', () => {
|
|
|
473
628
|
noSuchKeyError.name = 'NoSuchKey';
|
|
474
629
|
s3Client.send.rejects(noSuchKeyError);
|
|
475
630
|
|
|
476
|
-
const config = await client.fetchConfig('
|
|
631
|
+
const config = await client.fetchConfig('https://example.com/page1');
|
|
477
632
|
|
|
478
633
|
expect(config).to.be.null;
|
|
479
634
|
});
|
|
@@ -483,17 +638,17 @@ describe('TokowakaClient', () => {
|
|
|
483
638
|
noSuchKeyError.Code = 'NoSuchKey';
|
|
484
639
|
s3Client.send.rejects(noSuchKeyError);
|
|
485
640
|
|
|
486
|
-
const config = await client.fetchConfig('
|
|
641
|
+
const config = await client.fetchConfig('https://example.com/page1');
|
|
487
642
|
|
|
488
643
|
expect(config).to.be.null;
|
|
489
644
|
});
|
|
490
645
|
|
|
491
|
-
it('should throw error if
|
|
646
|
+
it('should throw error if URL is missing', async () => {
|
|
492
647
|
try {
|
|
493
648
|
await client.fetchConfig('');
|
|
494
649
|
expect.fail('Should have thrown error');
|
|
495
650
|
} catch (error) {
|
|
496
|
-
expect(error.message).to.include('
|
|
651
|
+
expect(error.message).to.include('URL is required');
|
|
497
652
|
expect(error.status).to.equal(400);
|
|
498
653
|
}
|
|
499
654
|
});
|
|
@@ -502,7 +657,7 @@ describe('TokowakaClient', () => {
|
|
|
502
657
|
s3Client.send.rejects(new Error('Network error'));
|
|
503
658
|
|
|
504
659
|
try {
|
|
505
|
-
await client.fetchConfig('
|
|
660
|
+
await client.fetchConfig('https://example.com/page1');
|
|
506
661
|
expect.fail('Should have thrown error');
|
|
507
662
|
} catch (error) {
|
|
508
663
|
expect(error.message).to.include('S3 fetch failed');
|
|
@@ -517,58 +672,48 @@ describe('TokowakaClient', () => {
|
|
|
517
672
|
|
|
518
673
|
beforeEach(() => {
|
|
519
674
|
existingConfig = {
|
|
520
|
-
|
|
521
|
-
baseURL: 'https://example.com',
|
|
675
|
+
url: 'https://example.com/page1',
|
|
522
676
|
version: '1.0',
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
prerenderRequired: true,
|
|
535
|
-
lastUpdated: 1234567890,
|
|
536
|
-
},
|
|
537
|
-
{
|
|
538
|
-
op: 'replace',
|
|
539
|
-
selector: 'h2',
|
|
540
|
-
value: 'Old Subtitle',
|
|
541
|
-
opportunityId: 'opp-456',
|
|
542
|
-
suggestionId: 'sugg-2',
|
|
543
|
-
prerenderRequired: true,
|
|
544
|
-
lastUpdated: 1234567890,
|
|
545
|
-
},
|
|
546
|
-
],
|
|
677
|
+
forceFail: false,
|
|
678
|
+
prerender: true,
|
|
679
|
+
patches: [
|
|
680
|
+
{
|
|
681
|
+
op: 'replace',
|
|
682
|
+
selector: 'h1',
|
|
683
|
+
value: 'Old Heading',
|
|
684
|
+
opportunityId: 'opp-123',
|
|
685
|
+
suggestionId: 'sugg-1',
|
|
686
|
+
prerenderRequired: true,
|
|
687
|
+
lastUpdated: 1234567890,
|
|
547
688
|
},
|
|
548
|
-
|
|
689
|
+
{
|
|
690
|
+
op: 'replace',
|
|
691
|
+
selector: 'h2',
|
|
692
|
+
value: 'Old Subtitle',
|
|
693
|
+
opportunityId: 'opp-456',
|
|
694
|
+
suggestionId: 'sugg-2',
|
|
695
|
+
prerenderRequired: true,
|
|
696
|
+
lastUpdated: 1234567890,
|
|
697
|
+
},
|
|
698
|
+
],
|
|
549
699
|
};
|
|
550
700
|
|
|
551
701
|
newConfig = {
|
|
552
|
-
|
|
553
|
-
baseURL: 'https://example.com',
|
|
702
|
+
url: 'https://example.com/page1',
|
|
554
703
|
version: '1.0',
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
prerenderRequired: true,
|
|
567
|
-
lastUpdated: 1234567900,
|
|
568
|
-
},
|
|
569
|
-
],
|
|
704
|
+
forceFail: false,
|
|
705
|
+
prerender: true,
|
|
706
|
+
patches: [
|
|
707
|
+
{
|
|
708
|
+
op: 'replace',
|
|
709
|
+
selector: 'h1',
|
|
710
|
+
value: 'Updated Heading',
|
|
711
|
+
opportunityId: 'opp-123',
|
|
712
|
+
suggestionId: 'sugg-1',
|
|
713
|
+
prerenderRequired: true,
|
|
714
|
+
lastUpdated: 1234567900,
|
|
570
715
|
},
|
|
571
|
-
|
|
716
|
+
],
|
|
572
717
|
};
|
|
573
718
|
});
|
|
574
719
|
|
|
@@ -581,21 +726,21 @@ describe('TokowakaClient', () => {
|
|
|
581
726
|
it('should update existing patch with same opportunityId and suggestionId', () => {
|
|
582
727
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
583
728
|
|
|
584
|
-
expect(merged.
|
|
729
|
+
expect(merged.patches).to.have.length(2);
|
|
585
730
|
|
|
586
731
|
// First patch should be updated
|
|
587
|
-
const updatedPatch = merged.
|
|
732
|
+
const updatedPatch = merged.patches[0];
|
|
588
733
|
expect(updatedPatch.value).to.equal('Updated Heading');
|
|
589
734
|
expect(updatedPatch.lastUpdated).to.equal(1234567900);
|
|
590
735
|
|
|
591
736
|
// Second patch should remain unchanged
|
|
592
|
-
const unchangedPatch = merged.
|
|
737
|
+
const unchangedPatch = merged.patches[1];
|
|
593
738
|
expect(unchangedPatch.value).to.equal('Old Subtitle');
|
|
594
739
|
expect(unchangedPatch.opportunityId).to.equal('opp-456');
|
|
595
740
|
});
|
|
596
741
|
|
|
597
742
|
it('should add new patch if opportunityId and suggestionId do not exist', () => {
|
|
598
|
-
newConfig.
|
|
743
|
+
newConfig.patches.push({
|
|
599
744
|
op: 'replace',
|
|
600
745
|
selector: 'h3',
|
|
601
746
|
value: 'New Section Title',
|
|
@@ -607,106 +752,59 @@ describe('TokowakaClient', () => {
|
|
|
607
752
|
|
|
608
753
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
609
754
|
|
|
610
|
-
expect(merged.
|
|
755
|
+
expect(merged.patches).to.have.length(3);
|
|
611
756
|
|
|
612
757
|
// New patch should be added at the end
|
|
613
|
-
const newPatch = merged.
|
|
758
|
+
const newPatch = merged.patches[2];
|
|
614
759
|
expect(newPatch.value).to.equal('New Section Title');
|
|
615
760
|
expect(newPatch.opportunityId).to.equal('opp-789');
|
|
616
761
|
expect(newPatch.suggestionId).to.equal('sugg-3');
|
|
617
762
|
});
|
|
618
763
|
|
|
619
|
-
it('should add new URL path if it does not exist in existing config', () => {
|
|
620
|
-
newConfig.tokowakaOptimizations['/page2'] = {
|
|
621
|
-
prerender: true,
|
|
622
|
-
patches: [
|
|
623
|
-
{
|
|
624
|
-
op: 'replace',
|
|
625
|
-
selector: 'h1',
|
|
626
|
-
value: 'Page 2 Heading',
|
|
627
|
-
opportunityId: 'opp-999',
|
|
628
|
-
suggestionId: 'sugg-4',
|
|
629
|
-
prerenderRequired: true,
|
|
630
|
-
lastUpdated: 1234567900,
|
|
631
|
-
},
|
|
632
|
-
],
|
|
633
|
-
};
|
|
634
|
-
|
|
635
|
-
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
636
|
-
|
|
637
|
-
expect(merged.tokowakaOptimizations).to.have.property('/page1');
|
|
638
|
-
expect(merged.tokowakaOptimizations).to.have.property('/page2');
|
|
639
|
-
expect(merged.tokowakaOptimizations['/page2'].patches).to.have.length(1);
|
|
640
|
-
expect(merged.tokowakaOptimizations['/page2'].patches[0].value).to.equal('Page 2 Heading');
|
|
641
|
-
});
|
|
642
|
-
|
|
643
|
-
it('should preserve existing URL paths not present in new config', () => {
|
|
644
|
-
existingConfig.tokowakaOptimizations['/page3'] = {
|
|
645
|
-
prerender: false,
|
|
646
|
-
patches: [
|
|
647
|
-
{
|
|
648
|
-
op: 'replace',
|
|
649
|
-
selector: 'h1',
|
|
650
|
-
value: 'Page 3 Heading',
|
|
651
|
-
opportunityId: 'opp-333',
|
|
652
|
-
suggestionId: 'sugg-5',
|
|
653
|
-
prerenderRequired: false,
|
|
654
|
-
lastUpdated: 1234567890,
|
|
655
|
-
},
|
|
656
|
-
],
|
|
657
|
-
};
|
|
658
|
-
|
|
659
|
-
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
660
|
-
|
|
661
|
-
expect(merged.tokowakaOptimizations).to.have.property('/page1');
|
|
662
|
-
expect(merged.tokowakaOptimizations).to.have.property('/page3');
|
|
663
|
-
expect(merged.tokowakaOptimizations['/page3'].patches[0].value).to.equal('Page 3 Heading');
|
|
664
|
-
});
|
|
665
|
-
|
|
666
764
|
it('should update config metadata from new config', () => {
|
|
667
765
|
newConfig.version = '2.0';
|
|
668
|
-
newConfig.
|
|
766
|
+
newConfig.forceFail = true;
|
|
669
767
|
|
|
670
768
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
671
769
|
|
|
672
770
|
expect(merged.version).to.equal('2.0');
|
|
673
|
-
expect(merged.
|
|
771
|
+
expect(merged.forceFail).to.equal(true);
|
|
674
772
|
});
|
|
675
773
|
|
|
676
774
|
it('should handle empty patches array in existing config', () => {
|
|
677
|
-
existingConfig.
|
|
775
|
+
existingConfig.patches = [];
|
|
678
776
|
|
|
679
777
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
680
778
|
|
|
681
|
-
expect(merged.
|
|
682
|
-
expect(merged.
|
|
779
|
+
expect(merged.patches).to.have.length(1);
|
|
780
|
+
expect(merged.patches[0].value).to.equal('Updated Heading');
|
|
683
781
|
});
|
|
684
782
|
|
|
685
783
|
it('should handle empty patches array in new config', () => {
|
|
686
|
-
newConfig.
|
|
784
|
+
newConfig.patches = [];
|
|
687
785
|
|
|
688
786
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
689
787
|
|
|
690
|
-
expect(merged.
|
|
691
|
-
expect(merged.
|
|
788
|
+
expect(merged.patches).to.have.length(2);
|
|
789
|
+
expect(merged.patches[0].value).to.equal('Old Heading');
|
|
692
790
|
});
|
|
693
791
|
|
|
694
|
-
it('should handle
|
|
695
|
-
|
|
792
|
+
it('should handle undefined patches in existing config', () => {
|
|
793
|
+
existingConfig.patches = undefined;
|
|
696
794
|
|
|
697
795
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
698
796
|
|
|
699
|
-
expect(merged.
|
|
700
|
-
expect(merged.
|
|
797
|
+
expect(merged.patches).to.have.length(1);
|
|
798
|
+
expect(merged.patches[0].value).to.equal('Updated Heading');
|
|
701
799
|
});
|
|
702
800
|
|
|
703
|
-
it('should handle
|
|
704
|
-
|
|
801
|
+
it('should handle undefined patches in new config', () => {
|
|
802
|
+
newConfig.patches = undefined;
|
|
705
803
|
|
|
706
804
|
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
707
805
|
|
|
708
|
-
expect(merged.
|
|
709
|
-
expect(merged.
|
|
806
|
+
expect(merged.patches).to.have.length(2);
|
|
807
|
+
expect(merged.patches[0].value).to.equal('Old Heading');
|
|
710
808
|
});
|
|
711
809
|
});
|
|
712
810
|
|
|
@@ -720,6 +818,10 @@ describe('TokowakaClient', () => {
|
|
|
720
818
|
});
|
|
721
819
|
// Stub fetchConfig to return null by default (no existing config)
|
|
722
820
|
sinon.stub(client, 'fetchConfig').resolves(null);
|
|
821
|
+
// Stub fetchMetaconfig to return null by default (will create new)
|
|
822
|
+
sinon.stub(client, 'fetchMetaconfig').resolves(null);
|
|
823
|
+
// Stub uploadMetaconfig
|
|
824
|
+
sinon.stub(client, 'uploadMetaconfig').resolves('opportunities/example.com/config');
|
|
723
825
|
});
|
|
724
826
|
|
|
725
827
|
it('should deploy suggestions successfully', async () => {
|
|
@@ -729,33 +831,98 @@ describe('TokowakaClient', () => {
|
|
|
729
831
|
mockSuggestions,
|
|
730
832
|
);
|
|
731
833
|
|
|
732
|
-
expect(result).to.have.property('
|
|
733
|
-
expect(
|
|
834
|
+
expect(result).to.have.property('s3Paths');
|
|
835
|
+
expect(result.s3Paths).to.be.an('array').with.length(1);
|
|
836
|
+
expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
837
|
+
expect(result).to.have.property('cdnInvalidations');
|
|
838
|
+
expect(result.cdnInvalidations).to.be.an('array').with.length(1);
|
|
839
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
840
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
841
|
+
expect(s3Client.send).to.have.been.called;
|
|
734
842
|
});
|
|
735
843
|
|
|
736
|
-
it('should
|
|
737
|
-
|
|
738
|
-
|
|
844
|
+
it('should create metaconfig on first deployment', async () => {
|
|
845
|
+
await client.deploySuggestions(
|
|
846
|
+
mockSite,
|
|
847
|
+
mockOpportunity,
|
|
848
|
+
mockSuggestions,
|
|
849
|
+
);
|
|
850
|
+
|
|
851
|
+
expect(client.fetchMetaconfig).to.have.been.calledOnce;
|
|
852
|
+
expect(client.uploadMetaconfig).to.have.been.calledOnce;
|
|
853
|
+
|
|
854
|
+
const metaconfigArg = client.uploadMetaconfig.firstCall.args[1];
|
|
855
|
+
expect(metaconfigArg).to.deep.include({
|
|
856
|
+
siteId: 'site-123',
|
|
857
|
+
prerender: true,
|
|
739
858
|
});
|
|
859
|
+
});
|
|
740
860
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
861
|
+
it('should reuse existing metaconfig', async () => {
|
|
862
|
+
client.fetchMetaconfig.resolves({
|
|
863
|
+
siteId: 'site-123',
|
|
864
|
+
prerender: true,
|
|
865
|
+
});
|
|
866
|
+
|
|
867
|
+
await client.deploySuggestions(
|
|
868
|
+
mockSite,
|
|
869
|
+
mockOpportunity,
|
|
870
|
+
mockSuggestions,
|
|
871
|
+
);
|
|
872
|
+
|
|
873
|
+
expect(client.fetchMetaconfig).to.have.been.calledOnce;
|
|
874
|
+
expect(client.uploadMetaconfig).to.not.have.been.called;
|
|
875
|
+
});
|
|
876
|
+
|
|
877
|
+
it('should handle suggestions for multiple URLs', async () => {
|
|
878
|
+
mockSuggestions = [
|
|
879
|
+
{
|
|
880
|
+
getId: () => 'sugg-1',
|
|
881
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
882
|
+
getData: () => ({
|
|
883
|
+
url: 'https://example.com/page1',
|
|
884
|
+
recommendedAction: 'Page 1 Heading',
|
|
885
|
+
checkType: 'heading-empty',
|
|
886
|
+
transformRules: {
|
|
887
|
+
action: 'replace',
|
|
888
|
+
selector: 'h1',
|
|
889
|
+
},
|
|
890
|
+
}),
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
getId: () => 'sugg-2',
|
|
894
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
895
|
+
getData: () => ({
|
|
896
|
+
url: 'https://example.com/page2',
|
|
897
|
+
recommendedAction: 'Page 2 Heading',
|
|
898
|
+
checkType: 'heading-empty',
|
|
899
|
+
transformRules: {
|
|
900
|
+
action: 'replace',
|
|
901
|
+
selector: 'h1',
|
|
902
|
+
},
|
|
903
|
+
}),
|
|
904
|
+
},
|
|
905
|
+
];
|
|
906
|
+
|
|
907
|
+
const result = await client.deploySuggestions(
|
|
908
|
+
mockSite,
|
|
909
|
+
mockOpportunity,
|
|
910
|
+
mockSuggestions,
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
expect(result.s3Paths).to.have.length(2);
|
|
914
|
+
expect(result.cdnInvalidations).to.have.length(2);
|
|
915
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
748
916
|
});
|
|
749
917
|
|
|
750
918
|
it('should handle suggestions that are not eligible for deployment', async () => {
|
|
751
|
-
// Create suggestions with different checkTypes
|
|
752
919
|
mockSuggestions = [
|
|
753
920
|
{
|
|
754
921
|
getId: () => 'sugg-1',
|
|
755
922
|
getData: () => ({
|
|
756
923
|
url: 'https://example.com/page1',
|
|
757
924
|
recommendedAction: 'New Heading',
|
|
758
|
-
checkType: 'heading-missing', // Not eligible
|
|
925
|
+
checkType: 'heading-missing', // Not eligible
|
|
759
926
|
}),
|
|
760
927
|
},
|
|
761
928
|
{
|
|
@@ -784,15 +951,27 @@ describe('TokowakaClient', () => {
|
|
|
784
951
|
expect(result.failedSuggestions[0].reason).to.include('can be deployed');
|
|
785
952
|
});
|
|
786
953
|
|
|
787
|
-
it('should
|
|
788
|
-
// All suggestions are ineligible
|
|
954
|
+
it('should handle multi-URL deploy where one URL has no eligible suggestions', async () => {
|
|
789
955
|
mockSuggestions = [
|
|
790
956
|
{
|
|
791
957
|
getId: () => 'sugg-1',
|
|
958
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
792
959
|
getData: () => ({
|
|
793
960
|
url: 'https://example.com/page1',
|
|
794
961
|
recommendedAction: 'New Heading',
|
|
795
|
-
checkType: 'heading-
|
|
962
|
+
checkType: 'heading-empty', // Eligible
|
|
963
|
+
transformRules: {
|
|
964
|
+
action: 'replace',
|
|
965
|
+
selector: 'h1',
|
|
966
|
+
},
|
|
967
|
+
}),
|
|
968
|
+
},
|
|
969
|
+
{
|
|
970
|
+
getId: () => 'sugg-2',
|
|
971
|
+
getData: () => ({
|
|
972
|
+
url: 'https://example.com/page2',
|
|
973
|
+
recommendedAction: 'New Heading',
|
|
974
|
+
checkType: 'heading-missing', // Not eligible
|
|
796
975
|
}),
|
|
797
976
|
},
|
|
798
977
|
];
|
|
@@ -803,20 +982,32 @@ describe('TokowakaClient', () => {
|
|
|
803
982
|
mockSuggestions,
|
|
804
983
|
);
|
|
805
984
|
|
|
806
|
-
expect(result.succeededSuggestions).to.have.length(
|
|
985
|
+
expect(result.succeededSuggestions).to.have.length(1);
|
|
807
986
|
expect(result.failedSuggestions).to.have.length(1);
|
|
808
|
-
expect(
|
|
809
|
-
expect(s3Client.send).to.not.have.been.called;
|
|
987
|
+
expect(result.failedSuggestions[0].suggestion.getId()).to.equal('sugg-2');
|
|
810
988
|
});
|
|
811
989
|
|
|
812
|
-
it('should
|
|
813
|
-
//
|
|
990
|
+
it('should skip URL when generateConfig returns no patches', async () => {
|
|
991
|
+
// Stub mapper to return empty patches for the first call, normal for subsequent calls
|
|
992
|
+
const mapper = client.mapperRegistry.getMapper('headings');
|
|
993
|
+
const originalSuggestionsToPatches = mapper.suggestionsToPatches.bind(mapper);
|
|
994
|
+
let callCount = 0;
|
|
995
|
+
sinon.stub(mapper, 'suggestionsToPatches').callsFake((...args) => {
|
|
996
|
+
callCount += 1;
|
|
997
|
+
if (callCount === 1) {
|
|
998
|
+
// First call (for page1) returns no patches
|
|
999
|
+
return [];
|
|
1000
|
+
}
|
|
1001
|
+
// Subsequent calls work normally
|
|
1002
|
+
return originalSuggestionsToPatches(...args);
|
|
1003
|
+
});
|
|
1004
|
+
|
|
814
1005
|
mockSuggestions = [
|
|
815
1006
|
{
|
|
816
1007
|
getId: () => 'sugg-1',
|
|
817
1008
|
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
818
1009
|
getData: () => ({
|
|
819
|
-
|
|
1010
|
+
url: 'https://example.com/page1',
|
|
820
1011
|
recommendedAction: 'New Heading',
|
|
821
1012
|
checkType: 'heading-empty',
|
|
822
1013
|
transformRules: {
|
|
@@ -825,6 +1016,42 @@ describe('TokowakaClient', () => {
|
|
|
825
1016
|
},
|
|
826
1017
|
}),
|
|
827
1018
|
},
|
|
1019
|
+
{
|
|
1020
|
+
getId: () => 'sugg-2',
|
|
1021
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1022
|
+
getData: () => ({
|
|
1023
|
+
url: 'https://example.com/page2',
|
|
1024
|
+
recommendedAction: 'New Subtitle',
|
|
1025
|
+
checkType: 'heading-empty',
|
|
1026
|
+
transformRules: {
|
|
1027
|
+
action: 'replace',
|
|
1028
|
+
selector: 'h2',
|
|
1029
|
+
},
|
|
1030
|
+
}),
|
|
1031
|
+
},
|
|
1032
|
+
];
|
|
1033
|
+
|
|
1034
|
+
const result = await client.deploySuggestions(
|
|
1035
|
+
mockSite,
|
|
1036
|
+
mockOpportunity,
|
|
1037
|
+
mockSuggestions,
|
|
1038
|
+
);
|
|
1039
|
+
|
|
1040
|
+
// Both suggestions are in result but sugg-1 skipped deployment due to no patches
|
|
1041
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1042
|
+
expect(result.s3Paths).to.have.length(1); // Only one URL actually deployed
|
|
1043
|
+
});
|
|
1044
|
+
|
|
1045
|
+
it('should return early when no eligible suggestions', async () => {
|
|
1046
|
+
mockSuggestions = [
|
|
1047
|
+
{
|
|
1048
|
+
getId: () => 'sugg-1',
|
|
1049
|
+
getData: () => ({
|
|
1050
|
+
url: 'https://example.com/page1',
|
|
1051
|
+
recommendedAction: 'New Heading',
|
|
1052
|
+
checkType: 'heading-missing', // Wrong checkType name, not eligible
|
|
1053
|
+
}),
|
|
1054
|
+
},
|
|
828
1055
|
];
|
|
829
1056
|
|
|
830
1057
|
const result = await client.deploySuggestions(
|
|
@@ -835,6 +1062,7 @@ describe('TokowakaClient', () => {
|
|
|
835
1062
|
|
|
836
1063
|
expect(result.succeededSuggestions).to.have.length(0);
|
|
837
1064
|
expect(result.failedSuggestions).to.have.length(1);
|
|
1065
|
+
expect(log.warn).to.have.been.calledWith('No eligible suggestions to deploy');
|
|
838
1066
|
expect(s3Client.send).to.not.have.been.called;
|
|
839
1067
|
});
|
|
840
1068
|
|
|
@@ -846,63 +1074,27 @@ describe('TokowakaClient', () => {
|
|
|
846
1074
|
expect.fail('Should have thrown error');
|
|
847
1075
|
} catch (error) {
|
|
848
1076
|
expect(error.message).to.include('No mapper found for opportunity type: unsupported-type');
|
|
849
|
-
expect(error.message).to.include('Supported types:');
|
|
850
1077
|
expect(error.status).to.equal(501);
|
|
851
1078
|
}
|
|
852
1079
|
});
|
|
853
1080
|
|
|
854
|
-
it('should handle null tokowakaConfig gracefully', async () => {
|
|
855
|
-
mockSite.getConfig = () => ({
|
|
856
|
-
getTokowakaConfig: () => null,
|
|
857
|
-
});
|
|
858
|
-
|
|
859
|
-
try {
|
|
860
|
-
await client.deploySuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
861
|
-
expect.fail('Should have thrown error');
|
|
862
|
-
} catch (error) {
|
|
863
|
-
expect(error.message).to.include('Tokowaka API key configured');
|
|
864
|
-
}
|
|
865
|
-
});
|
|
866
|
-
|
|
867
|
-
it('should use default reason when eligibility has no reason', async () => {
|
|
868
|
-
// Create a mock mapper that returns eligible=false without reason
|
|
869
|
-
const mockMapper = {
|
|
870
|
-
canDeploy: sinon.stub().returns({ eligible: false }), // No reason provided
|
|
871
|
-
};
|
|
872
|
-
sinon.stub(client.mapperRegistry, 'getMapper').returns(mockMapper);
|
|
873
|
-
|
|
874
|
-
const result = await client.deploySuggestions(
|
|
875
|
-
mockSite,
|
|
876
|
-
mockOpportunity,
|
|
877
|
-
mockSuggestions,
|
|
878
|
-
);
|
|
879
|
-
|
|
880
|
-
expect(result.failedSuggestions).to.have.length(2);
|
|
881
|
-
expect(result.failedSuggestions[0].reason).to.equal('Suggestion cannot be deployed');
|
|
882
|
-
});
|
|
883
|
-
|
|
884
1081
|
it('should fetch existing config and merge when deploying', async () => {
|
|
885
1082
|
const existingConfig = {
|
|
886
|
-
|
|
887
|
-
baseURL: 'https://example.com',
|
|
1083
|
+
url: 'https://example.com/page1',
|
|
888
1084
|
version: '1.0',
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
prerenderRequired: true,
|
|
901
|
-
lastUpdated: 1234567890,
|
|
902
|
-
},
|
|
903
|
-
],
|
|
1085
|
+
forceFail: false,
|
|
1086
|
+
prerender: true,
|
|
1087
|
+
patches: [
|
|
1088
|
+
{
|
|
1089
|
+
op: 'replace',
|
|
1090
|
+
selector: 'h3',
|
|
1091
|
+
value: 'Existing Heading',
|
|
1092
|
+
opportunityId: 'opp-999',
|
|
1093
|
+
suggestionId: 'sugg-999',
|
|
1094
|
+
prerenderRequired: true,
|
|
1095
|
+
lastUpdated: 1234567890,
|
|
904
1096
|
},
|
|
905
|
-
|
|
1097
|
+
],
|
|
906
1098
|
};
|
|
907
1099
|
|
|
908
1100
|
client.fetchConfig.resolves(existingConfig);
|
|
@@ -913,53 +1105,31 @@ describe('TokowakaClient', () => {
|
|
|
913
1105
|
mockSuggestions,
|
|
914
1106
|
);
|
|
915
1107
|
|
|
916
|
-
expect(client.fetchConfig).to.have.been.
|
|
917
|
-
expect(result).to.have.
|
|
1108
|
+
expect(client.fetchConfig).to.have.been.called;
|
|
1109
|
+
expect(result.s3Paths).to.have.length(1);
|
|
918
1110
|
|
|
919
1111
|
// Verify the uploaded config contains both existing and new patches
|
|
920
1112
|
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
921
|
-
expect(uploadedConfig.
|
|
922
|
-
});
|
|
923
|
-
|
|
924
|
-
it('should use new config when no existing config found', async () => {
|
|
925
|
-
client.fetchConfig.resolves(null);
|
|
926
|
-
|
|
927
|
-
const result = await client.deploySuggestions(
|
|
928
|
-
mockSite,
|
|
929
|
-
mockOpportunity,
|
|
930
|
-
mockSuggestions,
|
|
931
|
-
);
|
|
932
|
-
|
|
933
|
-
expect(client.fetchConfig).to.have.been.calledWith('test-api-key-123');
|
|
934
|
-
expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
|
|
935
|
-
|
|
936
|
-
// Verify only new patches are in the config
|
|
937
|
-
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
938
|
-
expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
1113
|
+
expect(uploadedConfig.patches).to.have.length(3);
|
|
939
1114
|
});
|
|
940
1115
|
|
|
941
1116
|
it('should update existing patch when deploying same opportunityId and suggestionId', async () => {
|
|
942
1117
|
const existingConfig = {
|
|
943
|
-
|
|
944
|
-
baseURL: 'https://example.com',
|
|
1118
|
+
url: 'https://example.com/page1',
|
|
945
1119
|
version: '1.0',
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
prerenderRequired: true,
|
|
958
|
-
lastUpdated: 1234567890,
|
|
959
|
-
},
|
|
960
|
-
],
|
|
1120
|
+
forceFail: false,
|
|
1121
|
+
prerender: true,
|
|
1122
|
+
patches: [
|
|
1123
|
+
{
|
|
1124
|
+
op: 'replace',
|
|
1125
|
+
selector: 'h1',
|
|
1126
|
+
value: 'Old Heading Value',
|
|
1127
|
+
opportunityId: 'opp-123',
|
|
1128
|
+
suggestionId: 'sugg-1',
|
|
1129
|
+
prerenderRequired: true,
|
|
1130
|
+
lastUpdated: 1234567890,
|
|
961
1131
|
},
|
|
962
|
-
|
|
1132
|
+
],
|
|
963
1133
|
};
|
|
964
1134
|
|
|
965
1135
|
client.fetchConfig.resolves(existingConfig);
|
|
@@ -970,152 +1140,788 @@ describe('TokowakaClient', () => {
|
|
|
970
1140
|
mockSuggestions,
|
|
971
1141
|
);
|
|
972
1142
|
|
|
973
|
-
expect(result).to.have.
|
|
1143
|
+
expect(result.s3Paths).to.have.length(1);
|
|
974
1144
|
|
|
975
1145
|
// Verify the patch was updated, not duplicated
|
|
976
1146
|
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
977
|
-
expect(uploadedConfig.
|
|
1147
|
+
expect(uploadedConfig.patches).to.have.length(2);
|
|
978
1148
|
|
|
979
1149
|
// First patch should be updated with new value
|
|
980
|
-
const updatedPatch = uploadedConfig.
|
|
1150
|
+
const updatedPatch = uploadedConfig.patches[0];
|
|
981
1151
|
expect(updatedPatch.value).to.equal('New Heading');
|
|
982
1152
|
expect(updatedPatch.opportunityId).to.equal('opp-123');
|
|
983
1153
|
expect(updatedPatch.suggestionId).to.equal('sugg-1');
|
|
984
1154
|
expect(updatedPatch.lastUpdated).to.be.greaterThan(1234567890);
|
|
985
1155
|
});
|
|
1156
|
+
});
|
|
1157
|
+
|
|
1158
|
+
describe('rollbackSuggestions', () => {
|
|
1159
|
+
beforeEach(() => {
|
|
1160
|
+
// Stub CDN invalidation for rollback tests
|
|
1161
|
+
sinon.stub(client, 'invalidateCdnCache').resolves({
|
|
1162
|
+
status: 'success',
|
|
1163
|
+
provider: 'cloudfront',
|
|
1164
|
+
invalidationId: 'I123',
|
|
1165
|
+
});
|
|
1166
|
+
});
|
|
986
1167
|
|
|
987
|
-
it('should
|
|
1168
|
+
it('should rollback suggestions successfully', async () => {
|
|
988
1169
|
const existingConfig = {
|
|
989
|
-
|
|
990
|
-
baseURL: 'https://example.com',
|
|
1170
|
+
url: 'https://example.com/page1',
|
|
991
1171
|
version: '1.0',
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1172
|
+
forceFail: false,
|
|
1173
|
+
prerender: true,
|
|
1174
|
+
patches: [
|
|
1175
|
+
{
|
|
1176
|
+
op: 'replace',
|
|
1177
|
+
selector: 'h1',
|
|
1178
|
+
value: 'Heading 1',
|
|
1179
|
+
opportunityId: 'opp-123',
|
|
1180
|
+
suggestionId: 'sugg-1',
|
|
1181
|
+
prerenderRequired: true,
|
|
1182
|
+
lastUpdated: 1234567890,
|
|
1183
|
+
},
|
|
1184
|
+
{
|
|
1185
|
+
op: 'replace',
|
|
1186
|
+
selector: 'h2',
|
|
1187
|
+
value: 'Heading 2',
|
|
1188
|
+
opportunityId: 'opp-123',
|
|
1189
|
+
suggestionId: 'sugg-2',
|
|
1190
|
+
prerenderRequired: true,
|
|
1191
|
+
lastUpdated: 1234567890,
|
|
1007
1192
|
},
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
suggestionId: 'sugg-888',
|
|
1017
|
-
prerenderRequired: false,
|
|
1018
|
-
lastUpdated: 1234567890,
|
|
1019
|
-
},
|
|
1020
|
-
],
|
|
1193
|
+
{
|
|
1194
|
+
op: 'replace',
|
|
1195
|
+
selector: 'h3',
|
|
1196
|
+
value: 'Heading 3',
|
|
1197
|
+
opportunityId: 'opp-123',
|
|
1198
|
+
suggestionId: 'sugg-3',
|
|
1199
|
+
prerenderRequired: true,
|
|
1200
|
+
lastUpdated: 1234567890,
|
|
1021
1201
|
},
|
|
1022
|
-
|
|
1202
|
+
],
|
|
1023
1203
|
};
|
|
1024
1204
|
|
|
1025
|
-
client
|
|
1205
|
+
sinon.stub(client, 'fetchConfig').resolves(existingConfig);
|
|
1026
1206
|
|
|
1027
|
-
const result = await client.
|
|
1207
|
+
const result = await client.rollbackSuggestions(
|
|
1028
1208
|
mockSite,
|
|
1029
1209
|
mockOpportunity,
|
|
1030
|
-
mockSuggestions,
|
|
1210
|
+
mockSuggestions, // Only sugg-1 and sugg-2
|
|
1031
1211
|
);
|
|
1032
1212
|
|
|
1033
|
-
expect(result).to.have.
|
|
1213
|
+
expect(result.s3Paths).to.have.length(1);
|
|
1214
|
+
expect(result.s3Paths[0]).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
1215
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1216
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1217
|
+
expect(result.removedPatchesCount).to.equal(2);
|
|
1034
1218
|
|
|
1035
|
-
// Verify
|
|
1219
|
+
// Verify uploaded config has sugg-3 but not sugg-1 and sugg-2
|
|
1036
1220
|
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
1037
|
-
expect(uploadedConfig.
|
|
1038
|
-
expect(uploadedConfig.
|
|
1039
|
-
expect(uploadedConfig.tokowakaOptimizations['/other-page'].patches[0].value)
|
|
1040
|
-
.to.equal('Other Page Heading');
|
|
1221
|
+
expect(uploadedConfig.patches).to.have.length(1);
|
|
1222
|
+
expect(uploadedConfig.patches[0].suggestionId).to.equal('sugg-3');
|
|
1041
1223
|
});
|
|
1042
|
-
});
|
|
1043
1224
|
|
|
1044
|
-
|
|
1045
|
-
|
|
1225
|
+
it('should handle no existing config gracefully', async () => {
|
|
1226
|
+
sinon.stub(client, 'fetchConfig').resolves(null);
|
|
1046
1227
|
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
invalidationId: 'I123',
|
|
1053
|
-
}),
|
|
1054
|
-
};
|
|
1228
|
+
const result = await client.rollbackSuggestions(
|
|
1229
|
+
mockSite,
|
|
1230
|
+
mockOpportunity,
|
|
1231
|
+
mockSuggestions,
|
|
1232
|
+
);
|
|
1055
1233
|
|
|
1056
|
-
|
|
1234
|
+
// Code continues and marks eligible suggestions as succeeded even if no config found
|
|
1235
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1236
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1237
|
+
expect(result.s3Paths).to.have.length(0);
|
|
1238
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
1057
1239
|
});
|
|
1058
1240
|
|
|
1059
|
-
it('should
|
|
1060
|
-
const
|
|
1241
|
+
it('should handle empty existing config patches', async () => {
|
|
1242
|
+
const existingConfig = {
|
|
1243
|
+
url: 'https://example.com/page1',
|
|
1244
|
+
version: '1.0',
|
|
1245
|
+
forceFail: false,
|
|
1246
|
+
prerender: true,
|
|
1247
|
+
patches: [],
|
|
1248
|
+
};
|
|
1061
1249
|
|
|
1062
|
-
|
|
1063
|
-
status: 'success',
|
|
1064
|
-
provider: 'cloudfront',
|
|
1065
|
-
invalidationId: 'I123',
|
|
1066
|
-
});
|
|
1250
|
+
sinon.stub(client, 'fetchConfig').resolves(existingConfig);
|
|
1067
1251
|
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1252
|
+
const result = await client.rollbackSuggestions(
|
|
1253
|
+
mockSite,
|
|
1254
|
+
mockOpportunity,
|
|
1255
|
+
mockSuggestions,
|
|
1256
|
+
);
|
|
1257
|
+
|
|
1258
|
+
// Code marks eligible suggestions as succeeded even if no patches to remove
|
|
1259
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1260
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1261
|
+
expect(result.s3Paths).to.have.length(0);
|
|
1262
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
it('should handle suggestions not found in config', async () => {
|
|
1266
|
+
const existingConfig = {
|
|
1267
|
+
url: 'https://example.com/page1',
|
|
1268
|
+
version: '1.0',
|
|
1269
|
+
forceFail: false,
|
|
1270
|
+
prerender: true,
|
|
1271
|
+
patches: [
|
|
1272
|
+
{
|
|
1273
|
+
op: 'replace',
|
|
1274
|
+
selector: 'h1',
|
|
1275
|
+
value: 'Heading',
|
|
1276
|
+
opportunityId: 'opp-123',
|
|
1277
|
+
suggestionId: 'sugg-999', // Different suggestion ID
|
|
1278
|
+
prerenderRequired: true,
|
|
1279
|
+
lastUpdated: 1234567890,
|
|
1280
|
+
},
|
|
1281
|
+
],
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
sinon.stub(client, 'fetchConfig').resolves(existingConfig);
|
|
1285
|
+
|
|
1286
|
+
const result = await client.rollbackSuggestions(
|
|
1287
|
+
mockSite,
|
|
1288
|
+
mockOpportunity,
|
|
1289
|
+
mockSuggestions,
|
|
1290
|
+
);
|
|
1291
|
+
|
|
1292
|
+
// Code marks eligible suggestions as succeeded even if patches not found
|
|
1293
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1294
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1295
|
+
expect(result.s3Paths).to.have.length(0);
|
|
1296
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
1297
|
+
});
|
|
1298
|
+
|
|
1299
|
+
it('should return early when all suggestions are ineligible for rollback', async () => {
|
|
1300
|
+
const ineligibleSuggestions = [
|
|
1301
|
+
{
|
|
1302
|
+
getId: () => 'sugg-1',
|
|
1303
|
+
getData: () => ({
|
|
1304
|
+
url: 'https://example.com/page1',
|
|
1305
|
+
recommendedAction: 'New Heading',
|
|
1306
|
+
checkType: 'heading-missing', // Not eligible
|
|
1307
|
+
}),
|
|
1308
|
+
},
|
|
1309
|
+
{
|
|
1310
|
+
getId: () => 'sugg-2',
|
|
1311
|
+
getData: () => ({
|
|
1312
|
+
url: 'https://example.com/page1',
|
|
1313
|
+
recommendedAction: 'New Subtitle',
|
|
1314
|
+
checkType: 'heading-wrong', // Not eligible
|
|
1315
|
+
}),
|
|
1316
|
+
},
|
|
1317
|
+
];
|
|
1318
|
+
|
|
1319
|
+
const result = await client.rollbackSuggestions(
|
|
1320
|
+
mockSite,
|
|
1321
|
+
mockOpportunity,
|
|
1322
|
+
ineligibleSuggestions,
|
|
1323
|
+
);
|
|
1324
|
+
|
|
1325
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
1326
|
+
expect(result.failedSuggestions).to.have.length(2);
|
|
1327
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
it('should delete config file when all patches are rolled back', async () => {
|
|
1331
|
+
// Code uploads empty config instead of deleting
|
|
1332
|
+
const existingConfig = {
|
|
1333
|
+
url: 'https://example.com/page1',
|
|
1334
|
+
version: '1.0',
|
|
1335
|
+
forceFail: false,
|
|
1336
|
+
prerender: true,
|
|
1337
|
+
patches: [
|
|
1338
|
+
{
|
|
1339
|
+
op: 'replace',
|
|
1340
|
+
selector: 'h1',
|
|
1341
|
+
value: 'Heading 1',
|
|
1342
|
+
opportunityId: 'opp-123',
|
|
1343
|
+
suggestionId: 'sugg-1',
|
|
1344
|
+
prerenderRequired: true,
|
|
1345
|
+
lastUpdated: 1234567890,
|
|
1346
|
+
},
|
|
1347
|
+
],
|
|
1348
|
+
};
|
|
1349
|
+
|
|
1350
|
+
sinon.stub(client, 'fetchConfig').resolves(existingConfig);
|
|
1351
|
+
|
|
1352
|
+
const result = await client.rollbackSuggestions(
|
|
1353
|
+
mockSite,
|
|
1354
|
+
mockOpportunity,
|
|
1355
|
+
[mockSuggestions[0]], // Only roll back sugg-1 (all patches for this URL)
|
|
1356
|
+
);
|
|
1357
|
+
|
|
1358
|
+
expect(result.succeededSuggestions).to.have.length(1);
|
|
1359
|
+
expect(result.removedPatchesCount).to.equal(1);
|
|
1360
|
+
|
|
1361
|
+
// Code uploads empty patches array instead of deleting
|
|
1362
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
1363
|
+
const command = s3Client.send.firstCall.args[0];
|
|
1364
|
+
expect(command.constructor.name).to.equal('PutObjectCommand');
|
|
1365
|
+
expect(command.input.Key).to.equal('opportunities/example.com/L3BhZ2Ux');
|
|
1366
|
+
});
|
|
1367
|
+
|
|
1368
|
+
it('should handle rollback for multiple URLs', async () => {
|
|
1369
|
+
mockSuggestions = [
|
|
1370
|
+
{
|
|
1371
|
+
getId: () => 'sugg-1',
|
|
1372
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1373
|
+
getData: () => ({
|
|
1374
|
+
url: 'https://example.com/page1',
|
|
1375
|
+
recommendedAction: 'Page 1 Heading',
|
|
1376
|
+
checkType: 'heading-empty',
|
|
1377
|
+
transformRules: {
|
|
1378
|
+
action: 'replace',
|
|
1379
|
+
selector: 'h1',
|
|
1380
|
+
},
|
|
1381
|
+
}),
|
|
1382
|
+
},
|
|
1383
|
+
{
|
|
1384
|
+
getId: () => 'sugg-2',
|
|
1385
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1386
|
+
getData: () => ({
|
|
1387
|
+
url: 'https://example.com/page2',
|
|
1388
|
+
recommendedAction: 'Page 2 Heading',
|
|
1389
|
+
checkType: 'heading-empty',
|
|
1390
|
+
transformRules: {
|
|
1391
|
+
action: 'replace',
|
|
1392
|
+
selector: 'h1',
|
|
1393
|
+
},
|
|
1394
|
+
}),
|
|
1395
|
+
},
|
|
1396
|
+
];
|
|
1397
|
+
|
|
1398
|
+
const config1 = {
|
|
1399
|
+
url: 'https://example.com/page1',
|
|
1400
|
+
version: '1.0',
|
|
1401
|
+
forceFail: false,
|
|
1402
|
+
prerender: true,
|
|
1403
|
+
patches: [
|
|
1404
|
+
{
|
|
1405
|
+
op: 'replace',
|
|
1406
|
+
selector: 'h1',
|
|
1407
|
+
value: 'Heading 1',
|
|
1408
|
+
opportunityId: 'opp-123',
|
|
1409
|
+
suggestionId: 'sugg-1',
|
|
1410
|
+
prerenderRequired: true,
|
|
1411
|
+
lastUpdated: 1234567890,
|
|
1412
|
+
},
|
|
1413
|
+
],
|
|
1414
|
+
};
|
|
1415
|
+
|
|
1416
|
+
const config2 = {
|
|
1417
|
+
url: 'https://example.com/page2',
|
|
1418
|
+
version: '1.0',
|
|
1419
|
+
forceFail: false,
|
|
1420
|
+
prerender: true,
|
|
1421
|
+
patches: [
|
|
1422
|
+
{
|
|
1423
|
+
op: 'replace',
|
|
1424
|
+
selector: 'h1',
|
|
1425
|
+
value: 'Heading 2',
|
|
1426
|
+
opportunityId: 'opp-123',
|
|
1427
|
+
suggestionId: 'sugg-2',
|
|
1428
|
+
prerenderRequired: true,
|
|
1429
|
+
lastUpdated: 1234567890,
|
|
1430
|
+
},
|
|
1431
|
+
],
|
|
1432
|
+
};
|
|
1433
|
+
|
|
1434
|
+
sinon.stub(client, 'fetchConfig')
|
|
1435
|
+
.onFirstCall()
|
|
1436
|
+
.resolves(config1)
|
|
1437
|
+
.onSecondCall()
|
|
1438
|
+
.resolves(config2);
|
|
1439
|
+
|
|
1440
|
+
const result = await client.rollbackSuggestions(
|
|
1441
|
+
mockSite,
|
|
1442
|
+
mockOpportunity,
|
|
1443
|
+
mockSuggestions,
|
|
1444
|
+
);
|
|
1445
|
+
|
|
1446
|
+
expect(result.s3Paths).to.have.length(2);
|
|
1447
|
+
expect(result.cdnInvalidations).to.have.length(2);
|
|
1448
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1073
1449
|
});
|
|
1074
1450
|
|
|
1075
|
-
it('should
|
|
1451
|
+
it('should throw error for unsupported opportunity type', async () => {
|
|
1452
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
1453
|
+
|
|
1076
1454
|
try {
|
|
1077
|
-
await client.
|
|
1455
|
+
await client.rollbackSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1456
|
+
expect.fail('Should have thrown error');
|
|
1457
|
+
} catch (error) {
|
|
1458
|
+
expect(error.message).to.include('No mapper found for opportunity type: unsupported-type');
|
|
1459
|
+
expect(error.status).to.equal(501);
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
|
|
1463
|
+
it('should remove FAQ heading patch when rolling back last FAQ suggestion', async () => {
|
|
1464
|
+
// Change opportunity to FAQ type
|
|
1465
|
+
mockOpportunity.getType = () => 'faq';
|
|
1466
|
+
|
|
1467
|
+
// Create FAQ suggestion
|
|
1468
|
+
const faqSuggestion = {
|
|
1469
|
+
getId: () => 'faq-sugg-1',
|
|
1470
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1471
|
+
getData: () => ({
|
|
1472
|
+
url: 'https://example.com/page1',
|
|
1473
|
+
shouldOptimize: true,
|
|
1474
|
+
item: {
|
|
1475
|
+
question: 'What is this?',
|
|
1476
|
+
answer: 'This is a FAQ',
|
|
1477
|
+
},
|
|
1478
|
+
transformRules: {
|
|
1479
|
+
action: 'appendChild',
|
|
1480
|
+
selector: 'body',
|
|
1481
|
+
},
|
|
1482
|
+
}),
|
|
1483
|
+
};
|
|
1484
|
+
|
|
1485
|
+
const existingConfig = {
|
|
1486
|
+
url: 'https://example.com/page1',
|
|
1487
|
+
version: '1.0',
|
|
1488
|
+
forceFail: false,
|
|
1489
|
+
prerender: true,
|
|
1490
|
+
patches: [
|
|
1491
|
+
{
|
|
1492
|
+
opportunityId: 'opp-123',
|
|
1493
|
+
// FAQ heading patch (no suggestionId)
|
|
1494
|
+
op: 'appendChild',
|
|
1495
|
+
selector: 'body',
|
|
1496
|
+
value: { type: 'element', tagName: 'h2', children: [{ type: 'text', value: 'FAQs' }] },
|
|
1497
|
+
prerenderRequired: true,
|
|
1498
|
+
lastUpdated: 1234567890,
|
|
1499
|
+
},
|
|
1500
|
+
{
|
|
1501
|
+
opportunityId: 'opp-123',
|
|
1502
|
+
suggestionId: 'faq-sugg-1',
|
|
1503
|
+
op: 'appendChild',
|
|
1504
|
+
selector: 'body',
|
|
1505
|
+
value: { type: 'element', tagName: 'div' },
|
|
1506
|
+
prerenderRequired: true,
|
|
1507
|
+
lastUpdated: 1234567890,
|
|
1508
|
+
},
|
|
1509
|
+
],
|
|
1510
|
+
};
|
|
1511
|
+
|
|
1512
|
+
sinon.stub(client, 'fetchConfig').resolves(existingConfig);
|
|
1513
|
+
|
|
1514
|
+
const result = await client.rollbackSuggestions(
|
|
1515
|
+
mockSite,
|
|
1516
|
+
mockOpportunity,
|
|
1517
|
+
[faqSuggestion],
|
|
1518
|
+
);
|
|
1519
|
+
|
|
1520
|
+
expect(result.succeededSuggestions).to.have.length(1);
|
|
1521
|
+
expect(result.removedPatchesCount).to.equal(2); // FAQ item + heading
|
|
1522
|
+
|
|
1523
|
+
// Code uploads empty config instead of deleting
|
|
1524
|
+
const command = s3Client.send.firstCall.args[0];
|
|
1525
|
+
expect(command.constructor.name).to.equal('PutObjectCommand');
|
|
1526
|
+
});
|
|
1527
|
+
});
|
|
1528
|
+
|
|
1529
|
+
describe('previewSuggestions', () => {
|
|
1530
|
+
let fetchStub;
|
|
1531
|
+
|
|
1532
|
+
beforeEach(() => {
|
|
1533
|
+
// Stub global fetch for HTML fetching
|
|
1534
|
+
fetchStub = sinon.stub(global, 'fetch');
|
|
1535
|
+
// Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
|
|
1536
|
+
fetchStub.resolves({
|
|
1537
|
+
ok: true,
|
|
1538
|
+
status: 200,
|
|
1539
|
+
statusText: 'OK',
|
|
1540
|
+
headers: {
|
|
1541
|
+
get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
|
|
1542
|
+
},
|
|
1543
|
+
text: async () => '<html><body>Test HTML</body></html>',
|
|
1544
|
+
});
|
|
1545
|
+
|
|
1546
|
+
// Stub CDN invalidation for preview tests
|
|
1547
|
+
sinon.stub(client, 'invalidateCdnCache').resolves({
|
|
1548
|
+
status: 'success',
|
|
1549
|
+
provider: 'cloudfront',
|
|
1550
|
+
invalidationId: 'I123',
|
|
1551
|
+
});
|
|
1552
|
+
|
|
1553
|
+
// Stub fetchConfig to return null by default (no existing config)
|
|
1554
|
+
sinon.stub(client, 'fetchConfig').resolves(null);
|
|
1555
|
+
|
|
1556
|
+
// Add TOKOWAKA_EDGE_URL to env
|
|
1557
|
+
client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
|
|
1558
|
+
});
|
|
1559
|
+
|
|
1560
|
+
afterEach(() => {
|
|
1561
|
+
// fetchStub will be restored by global afterEach sinon.restore()
|
|
1562
|
+
// Just clean up env changes
|
|
1563
|
+
delete client.env.TOKOWAKA_EDGE_URL;
|
|
1564
|
+
});
|
|
1565
|
+
|
|
1566
|
+
it('should preview suggestions successfully with HTML', async () => {
|
|
1567
|
+
const result = await client.previewSuggestions(
|
|
1568
|
+
mockSite,
|
|
1569
|
+
mockOpportunity,
|
|
1570
|
+
mockSuggestions,
|
|
1571
|
+
{ warmupDelayMs: 0 },
|
|
1572
|
+
);
|
|
1573
|
+
|
|
1574
|
+
expect(result).to.have.property('s3Path', 'preview/opportunities/example.com/L3BhZ2Ux');
|
|
1575
|
+
expect(result).to.have.property('succeededSuggestions');
|
|
1576
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1577
|
+
expect(result).to.have.property('failedSuggestions');
|
|
1578
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
1579
|
+
expect(result).to.have.property('html');
|
|
1580
|
+
expect(result.html).to.have.property('url', 'https://example.com/page1');
|
|
1581
|
+
expect(result.html).to.have.property('originalHtml');
|
|
1582
|
+
expect(result.html).to.have.property('optimizedHtml');
|
|
1583
|
+
expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
|
|
1584
|
+
expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
|
|
1585
|
+
|
|
1586
|
+
// Verify fetch was called for HTML fetching
|
|
1587
|
+
// (4 times: warmup + actual for original and optimized)
|
|
1588
|
+
expect(fetchStub.callCount).to.equal(4);
|
|
1589
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
1590
|
+
});
|
|
1591
|
+
|
|
1592
|
+
it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
|
|
1593
|
+
delete client.env.TOKOWAKA_EDGE_URL;
|
|
1594
|
+
|
|
1595
|
+
try {
|
|
1596
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1597
|
+
expect.fail('Should have thrown error');
|
|
1598
|
+
} catch (error) {
|
|
1599
|
+
expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
|
|
1600
|
+
expect(error.status).to.equal(500);
|
|
1601
|
+
}
|
|
1602
|
+
});
|
|
1603
|
+
|
|
1604
|
+
it('should throw error if site does not have forwardedHost', async () => {
|
|
1605
|
+
mockSite.getConfig = () => ({
|
|
1606
|
+
getTokowakaConfig: () => ({}),
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
try {
|
|
1610
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1078
1611
|
expect.fail('Should have thrown error');
|
|
1079
1612
|
} catch (error) {
|
|
1080
|
-
expect(error.message).to.
|
|
1613
|
+
expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
|
|
1081
1614
|
expect(error.status).to.equal(400);
|
|
1082
1615
|
}
|
|
1083
1616
|
});
|
|
1084
1617
|
|
|
1085
|
-
it('should
|
|
1618
|
+
it('should throw error if getTokowakaConfig returns null', async () => {
|
|
1619
|
+
mockSite.getConfig = () => ({
|
|
1620
|
+
getTokowakaConfig: () => null,
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1086
1623
|
try {
|
|
1087
|
-
await client.
|
|
1624
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1088
1625
|
expect.fail('Should have thrown error');
|
|
1089
1626
|
} catch (error) {
|
|
1090
|
-
expect(error.message).to.
|
|
1627
|
+
expect(error.message).to.include('Site does not have a Tokowaka API key or forwarded host configured');
|
|
1091
1628
|
expect(error.status).to.equal(400);
|
|
1092
1629
|
}
|
|
1093
1630
|
});
|
|
1094
1631
|
|
|
1095
|
-
it('should
|
|
1632
|
+
it('should throw error for unsupported opportunity type', async () => {
|
|
1633
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
1634
|
+
|
|
1635
|
+
try {
|
|
1636
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1637
|
+
expect.fail('Should have thrown error');
|
|
1638
|
+
} catch (error) {
|
|
1639
|
+
expect(error.message).to.include('No mapper found for opportunity type');
|
|
1640
|
+
expect(error.status).to.equal(501);
|
|
1641
|
+
}
|
|
1642
|
+
});
|
|
1643
|
+
|
|
1644
|
+
it('should handle ineligible suggestions', async () => {
|
|
1645
|
+
mockSuggestions = [
|
|
1646
|
+
{
|
|
1647
|
+
getId: () => 'sugg-1',
|
|
1648
|
+
getData: () => ({
|
|
1649
|
+
url: 'https://example.com/page1',
|
|
1650
|
+
recommendedAction: 'New Heading',
|
|
1651
|
+
checkType: 'heading-missing', // Not eligible
|
|
1652
|
+
}),
|
|
1653
|
+
},
|
|
1654
|
+
];
|
|
1655
|
+
|
|
1656
|
+
const result = await client.previewSuggestions(
|
|
1657
|
+
mockSite,
|
|
1658
|
+
mockOpportunity,
|
|
1659
|
+
mockSuggestions,
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
1663
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
1664
|
+
expect(result.config).to.be.null;
|
|
1665
|
+
});
|
|
1666
|
+
|
|
1667
|
+
it('should return early when generateConfig returns no patches', async () => {
|
|
1668
|
+
// Stub mapper to return eligible but no patches
|
|
1669
|
+
const mapper = client.mapperRegistry.getMapper('headings');
|
|
1670
|
+
sinon.stub(mapper, 'suggestionsToPatches').returns([]);
|
|
1671
|
+
|
|
1672
|
+
mockSuggestions = [
|
|
1673
|
+
{
|
|
1674
|
+
getId: () => 'sugg-1',
|
|
1675
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1676
|
+
getData: () => ({
|
|
1677
|
+
url: 'https://example.com/page1',
|
|
1678
|
+
recommendedAction: 'New Heading',
|
|
1679
|
+
checkType: 'heading-empty', // Eligible
|
|
1680
|
+
transformRules: {
|
|
1681
|
+
action: 'replace',
|
|
1682
|
+
selector: 'h1',
|
|
1683
|
+
},
|
|
1684
|
+
}),
|
|
1685
|
+
},
|
|
1686
|
+
];
|
|
1687
|
+
|
|
1688
|
+
const result = await client.previewSuggestions(
|
|
1689
|
+
mockSite,
|
|
1690
|
+
mockOpportunity,
|
|
1691
|
+
mockSuggestions,
|
|
1692
|
+
);
|
|
1693
|
+
|
|
1694
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
1695
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
1696
|
+
expect(result.config).to.be.null;
|
|
1697
|
+
});
|
|
1698
|
+
|
|
1699
|
+
it('should throw error when preview URL not found in suggestion data', async () => {
|
|
1700
|
+
mockSuggestions = [
|
|
1701
|
+
{
|
|
1702
|
+
getId: () => 'sugg-1',
|
|
1703
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1704
|
+
getData: () => ({
|
|
1705
|
+
// URL missing
|
|
1706
|
+
recommendedAction: 'New Heading',
|
|
1707
|
+
checkType: 'heading-empty',
|
|
1708
|
+
transformRules: {
|
|
1709
|
+
action: 'replace',
|
|
1710
|
+
selector: 'h1',
|
|
1711
|
+
},
|
|
1712
|
+
}),
|
|
1713
|
+
},
|
|
1714
|
+
];
|
|
1715
|
+
|
|
1716
|
+
try {
|
|
1717
|
+
await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
1718
|
+
expect.fail('Should have thrown error');
|
|
1719
|
+
} catch (error) {
|
|
1720
|
+
expect(error.message).to.include('Preview URL not found in suggestion data');
|
|
1721
|
+
expect(error.status).to.equal(400);
|
|
1722
|
+
}
|
|
1723
|
+
});
|
|
1724
|
+
|
|
1725
|
+
it('should throw error when HTML fetch fails', async () => {
|
|
1726
|
+
fetchStub.rejects(new Error('Network timeout'));
|
|
1727
|
+
|
|
1728
|
+
try {
|
|
1729
|
+
await client.previewSuggestions(
|
|
1730
|
+
mockSite,
|
|
1731
|
+
mockOpportunity,
|
|
1732
|
+
mockSuggestions,
|
|
1733
|
+
{ warmupDelayMs: 0 },
|
|
1734
|
+
);
|
|
1735
|
+
expect.fail('Should have thrown error');
|
|
1736
|
+
} catch (error) {
|
|
1737
|
+
expect(error.message).to.include('Preview failed: Unable to fetch HTML');
|
|
1738
|
+
expect(error.status).to.equal(500);
|
|
1739
|
+
}
|
|
1740
|
+
});
|
|
1741
|
+
|
|
1742
|
+
it('should merge with existing deployed patches for the same URL', async () => {
|
|
1743
|
+
// Setup existing config with deployed patches
|
|
1744
|
+
const existingConfig = {
|
|
1745
|
+
url: 'https://example.com/page1',
|
|
1746
|
+
version: '1.0',
|
|
1747
|
+
forceFail: false,
|
|
1748
|
+
prerender: true,
|
|
1749
|
+
patches: [
|
|
1750
|
+
{
|
|
1751
|
+
op: 'replace',
|
|
1752
|
+
selector: 'title',
|
|
1753
|
+
value: 'Deployed Title',
|
|
1754
|
+
opportunityId: 'opp-456',
|
|
1755
|
+
suggestionId: 'sugg-deployed',
|
|
1756
|
+
prerenderRequired: true,
|
|
1757
|
+
lastUpdated: 1234567890,
|
|
1758
|
+
},
|
|
1759
|
+
],
|
|
1760
|
+
};
|
|
1761
|
+
|
|
1762
|
+
client.fetchConfig.resolves(existingConfig);
|
|
1763
|
+
|
|
1764
|
+
const result = await client.previewSuggestions(
|
|
1765
|
+
mockSite,
|
|
1766
|
+
mockOpportunity,
|
|
1767
|
+
mockSuggestions,
|
|
1768
|
+
{ warmupDelayMs: 0 },
|
|
1769
|
+
);
|
|
1770
|
+
|
|
1771
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1772
|
+
|
|
1773
|
+
// Verify config was uploaded with merged patches
|
|
1774
|
+
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
1775
|
+
expect(uploadedConfig.patches).to.have.length(3);
|
|
1776
|
+
|
|
1777
|
+
// Should have existing deployed patch + 2 new preview patches
|
|
1778
|
+
const deployedPatch = uploadedConfig.patches
|
|
1779
|
+
.find((p) => p.suggestionId === 'sugg-deployed');
|
|
1780
|
+
expect(deployedPatch).to.exist;
|
|
1781
|
+
expect(deployedPatch.value).to.equal('Deployed Title');
|
|
1782
|
+
});
|
|
1783
|
+
|
|
1784
|
+
it('should upload config to preview S3 path', async () => {
|
|
1785
|
+
await client.previewSuggestions(
|
|
1786
|
+
mockSite,
|
|
1787
|
+
mockOpportunity,
|
|
1788
|
+
mockSuggestions,
|
|
1789
|
+
{ warmupDelayMs: 0 },
|
|
1790
|
+
);
|
|
1791
|
+
|
|
1792
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
1793
|
+
|
|
1794
|
+
const putCommand = s3Client.send.firstCall.args[0];
|
|
1795
|
+
expect(putCommand.input.Bucket).to.equal('test-preview-bucket');
|
|
1796
|
+
expect(putCommand.input.Key).to.equal('preview/opportunities/example.com/L3BhZ2Ux');
|
|
1797
|
+
});
|
|
1798
|
+
|
|
1799
|
+
it('should invalidate CDN cache for preview path', async () => {
|
|
1800
|
+
await client.previewSuggestions(
|
|
1801
|
+
mockSite,
|
|
1802
|
+
mockOpportunity,
|
|
1803
|
+
mockSuggestions,
|
|
1804
|
+
{ warmupDelayMs: 0 },
|
|
1805
|
+
);
|
|
1806
|
+
|
|
1807
|
+
expect(client.invalidateCdnCache).to.have.been.calledOnce;
|
|
1808
|
+
const { firstCall } = client.invalidateCdnCache;
|
|
1809
|
+
expect(firstCall.args[0]).to.equal('https://example.com/page1');
|
|
1810
|
+
expect(firstCall.args[1]).to.equal('cloudfront');
|
|
1811
|
+
expect(firstCall.args[2]).to.be.true; // isPreview
|
|
1812
|
+
});
|
|
1813
|
+
|
|
1814
|
+
it('should throw error if suggestions span multiple URLs', async () => {
|
|
1815
|
+
mockSuggestions = [
|
|
1816
|
+
{
|
|
1817
|
+
getId: () => 'sugg-1',
|
|
1818
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1819
|
+
getData: () => ({
|
|
1820
|
+
url: 'https://example.com/page1',
|
|
1821
|
+
recommendedAction: 'Page 1 Heading',
|
|
1822
|
+
checkType: 'heading-empty',
|
|
1823
|
+
transformRules: {
|
|
1824
|
+
action: 'replace',
|
|
1825
|
+
selector: 'h1',
|
|
1826
|
+
},
|
|
1827
|
+
}),
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
getId: () => 'sugg-2',
|
|
1831
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
1832
|
+
getData: () => ({
|
|
1833
|
+
url: 'https://example.com/page2', // Different URL
|
|
1834
|
+
recommendedAction: 'Page 2 Heading',
|
|
1835
|
+
checkType: 'heading-empty',
|
|
1836
|
+
transformRules: {
|
|
1837
|
+
action: 'replace',
|
|
1838
|
+
selector: 'h1',
|
|
1839
|
+
},
|
|
1840
|
+
}),
|
|
1841
|
+
},
|
|
1842
|
+
];
|
|
1843
|
+
|
|
1844
|
+
// Code doesn't validate multi-URL, silently uses first URL
|
|
1845
|
+
// fetchConfig and invalidateCdnCache already stubbed in beforeEach
|
|
1846
|
+
// Only need to stub uploadConfig
|
|
1847
|
+
sinon.stub(client, 'uploadConfig').resolves('preview/opportunities/example.com/L3BhZ2Ux');
|
|
1848
|
+
|
|
1849
|
+
const result = await client.previewSuggestions(
|
|
1850
|
+
mockSite,
|
|
1851
|
+
mockOpportunity,
|
|
1852
|
+
mockSuggestions,
|
|
1853
|
+
{ warmupDelayMs: 0 },
|
|
1854
|
+
);
|
|
1855
|
+
|
|
1856
|
+
// Preview succeeds, using first URL only
|
|
1857
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
1858
|
+
expect(result.config.url).to.equal('https://example.com/page1');
|
|
1859
|
+
});
|
|
1860
|
+
});
|
|
1861
|
+
|
|
1862
|
+
describe('invalidateCdnCache', () => {
|
|
1863
|
+
let mockCdnClient;
|
|
1864
|
+
|
|
1865
|
+
beforeEach(() => {
|
|
1866
|
+
mockCdnClient = {
|
|
1867
|
+
invalidateCache: sinon.stub().resolves({
|
|
1868
|
+
status: 'success',
|
|
1869
|
+
provider: 'cloudfront',
|
|
1870
|
+
invalidationId: 'I123',
|
|
1871
|
+
}),
|
|
1872
|
+
};
|
|
1873
|
+
|
|
1874
|
+
sinon.stub(client.cdnClientRegistry, 'getClient').returns(mockCdnClient);
|
|
1875
|
+
});
|
|
1876
|
+
|
|
1877
|
+
it('should invalidate CDN cache successfully', async () => {
|
|
1878
|
+
const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
|
|
1879
|
+
|
|
1880
|
+
expect(result).to.deep.equal({
|
|
1881
|
+
status: 'success',
|
|
1882
|
+
provider: 'cloudfront',
|
|
1883
|
+
invalidationId: 'I123',
|
|
1884
|
+
});
|
|
1885
|
+
|
|
1886
|
+
expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
|
|
1887
|
+
'/opportunities/example.com/L3BhZ2Ux',
|
|
1888
|
+
]);
|
|
1889
|
+
expect(log.debug).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
|
|
1890
|
+
expect(log.info).to.have.been.calledWith(sinon.match(/CDN cache invalidation completed/));
|
|
1891
|
+
});
|
|
1892
|
+
|
|
1893
|
+
it('should invalidate CDN cache for preview path', async () => {
|
|
1894
|
+
await client.invalidateCdnCache('https://example.com/page1', 'cloudfront', true);
|
|
1895
|
+
|
|
1896
|
+
expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
|
|
1897
|
+
'/preview/opportunities/example.com/L3BhZ2Ux',
|
|
1898
|
+
]);
|
|
1899
|
+
});
|
|
1900
|
+
|
|
1901
|
+
it('should throw error if URL is missing', async () => {
|
|
1096
1902
|
try {
|
|
1097
|
-
await client.invalidateCdnCache('
|
|
1903
|
+
await client.invalidateCdnCache('', 'cloudfront');
|
|
1098
1904
|
expect.fail('Should have thrown error');
|
|
1099
1905
|
} catch (error) {
|
|
1100
|
-
expect(error.message).to.equal('
|
|
1906
|
+
expect(error.message).to.equal('URL and provider are required');
|
|
1101
1907
|
expect(error.status).to.equal(400);
|
|
1102
1908
|
}
|
|
1103
1909
|
});
|
|
1104
1910
|
|
|
1105
|
-
it('should
|
|
1911
|
+
it('should throw error if provider is missing', async () => {
|
|
1106
1912
|
try {
|
|
1107
|
-
await client.invalidateCdnCache(
|
|
1913
|
+
await client.invalidateCdnCache('https://example.com/page1', '');
|
|
1108
1914
|
expect.fail('Should have thrown error');
|
|
1109
1915
|
} catch (error) {
|
|
1110
|
-
expect(error.message).to.equal('
|
|
1916
|
+
expect(error.message).to.equal('URL and provider are required');
|
|
1111
1917
|
expect(error.status).to.equal(400);
|
|
1112
1918
|
}
|
|
1113
1919
|
});
|
|
1114
1920
|
|
|
1115
|
-
it('should return
|
|
1921
|
+
it('should return error object if no CDN client available', async () => {
|
|
1116
1922
|
client.cdnClientRegistry.getClient.returns(null);
|
|
1117
1923
|
|
|
1118
|
-
const result = await client.invalidateCdnCache('
|
|
1924
|
+
const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
|
|
1119
1925
|
|
|
1120
1926
|
expect(result).to.deep.equal({
|
|
1121
1927
|
status: 'error',
|
|
@@ -1128,7 +1934,7 @@ describe('TokowakaClient', () => {
|
|
|
1128
1934
|
it('should return error object if CDN invalidation fails', async () => {
|
|
1129
1935
|
mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
|
|
1130
1936
|
|
|
1131
|
-
const result = await client.invalidateCdnCache('
|
|
1937
|
+
const result = await client.invalidateCdnCache('https://example.com/page1', 'cloudfront');
|
|
1132
1938
|
|
|
1133
1939
|
expect(result).to.deep.equal({
|
|
1134
1940
|
status: 'error',
|