@adobe/spacecat-shared-tokowaka-client 1.0.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/.mocha-multi.json +7 -0
- package/.nycrc.json +15 -0
- package/.releaserc.cjs +17 -0
- package/CHANGELOG.md +24 -0
- package/CODE_OF_CONDUCT.md +75 -0
- package/CONTRIBUTING.md +74 -0
- package/LICENSE.txt +203 -0
- package/README.md +101 -0
- package/package.json +53 -0
- package/src/cdn/base-cdn-client.js +50 -0
- package/src/cdn/cdn-client-registry.js +87 -0
- package/src/cdn/cloudfront-cdn-client.js +128 -0
- package/src/constants.js +17 -0
- package/src/index.d.ts +289 -0
- package/src/index.js +447 -0
- package/src/mappers/base-mapper.js +86 -0
- package/src/mappers/content-summarization-mapper.js +106 -0
- package/src/mappers/headings-mapper.js +118 -0
- package/src/mappers/mapper-registry.js +87 -0
- package/test/cdn/base-cdn-client.test.js +52 -0
- package/test/cdn/cdn-client-registry.test.js +179 -0
- package/test/cdn/cloudfront-cdn-client.test.js +330 -0
- package/test/index.test.js +1142 -0
- package/test/mappers/base-mapper.test.js +110 -0
- package/test/mappers/content-mapper.test.js +355 -0
- package/test/mappers/headings-mapper.test.js +428 -0
- package/test/mappers/mapper-registry.test.js +197 -0
- package/test/setup-env.js +18 -0
|
@@ -0,0 +1,1142 @@
|
|
|
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, use } from 'chai';
|
|
16
|
+
import sinon from 'sinon';
|
|
17
|
+
import sinonChai from 'sinon-chai';
|
|
18
|
+
import TokowakaClient from '../src/index.js';
|
|
19
|
+
|
|
20
|
+
use(sinonChai);
|
|
21
|
+
|
|
22
|
+
describe('TokowakaClient', () => {
|
|
23
|
+
let client;
|
|
24
|
+
let s3Client;
|
|
25
|
+
let log;
|
|
26
|
+
let mockSite;
|
|
27
|
+
let mockOpportunity;
|
|
28
|
+
let mockSuggestions;
|
|
29
|
+
|
|
30
|
+
beforeEach(() => {
|
|
31
|
+
s3Client = {
|
|
32
|
+
send: sinon.stub().resolves(),
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
log = {
|
|
36
|
+
info: sinon.stub(),
|
|
37
|
+
warn: sinon.stub(),
|
|
38
|
+
error: sinon.stub(),
|
|
39
|
+
debug: sinon.stub(),
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const env = {
|
|
43
|
+
TOKOWAKA_CDN_PROVIDER: 'cloudfront',
|
|
44
|
+
TOKOWAKA_CDN_CONFIG: JSON.stringify({
|
|
45
|
+
cloudfront: {
|
|
46
|
+
distributionId: 'E123456',
|
|
47
|
+
region: 'us-east-1',
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
client = new TokowakaClient(
|
|
53
|
+
{ bucketName: 'test-bucket', s3Client, env },
|
|
54
|
+
log,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
mockSite = {
|
|
58
|
+
getId: () => 'site-123',
|
|
59
|
+
getBaseURL: () => 'https://example.com',
|
|
60
|
+
getConfig: () => ({
|
|
61
|
+
getTokowakaConfig: () => ({ apiKey: 'test-api-key-123' }),
|
|
62
|
+
}),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
mockOpportunity = {
|
|
66
|
+
getId: () => 'opp-123',
|
|
67
|
+
getType: () => 'headings',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
mockSuggestions = [
|
|
71
|
+
{
|
|
72
|
+
getId: () => 'sugg-1',
|
|
73
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
74
|
+
getData: () => ({
|
|
75
|
+
url: 'https://example.com/page1',
|
|
76
|
+
recommendedAction: 'New Heading',
|
|
77
|
+
checkType: 'heading-empty',
|
|
78
|
+
transformRules: {
|
|
79
|
+
action: 'replace',
|
|
80
|
+
selector: 'h1',
|
|
81
|
+
},
|
|
82
|
+
}),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
getId: () => 'sugg-2',
|
|
86
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
87
|
+
getData: () => ({
|
|
88
|
+
url: 'https://example.com/page1',
|
|
89
|
+
recommendedAction: 'New Subtitle',
|
|
90
|
+
checkType: 'heading-empty',
|
|
91
|
+
transformRules: {
|
|
92
|
+
action: 'replace',
|
|
93
|
+
selector: 'h2',
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
];
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
afterEach(() => {
|
|
101
|
+
sinon.restore();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('constructor', () => {
|
|
105
|
+
it('should create an instance with valid config', () => {
|
|
106
|
+
expect(client).to.be.instanceOf(TokowakaClient);
|
|
107
|
+
expect(client.bucketName).to.equal('test-bucket');
|
|
108
|
+
expect(client.s3Client).to.equal(s3Client);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('should throw error if bucketName is missing', () => {
|
|
112
|
+
expect(() => new TokowakaClient({ s3Client }, log))
|
|
113
|
+
.to.throw('TOKOWAKA_SITE_CONFIG_BUCKET is required');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it('should throw error if s3Client is missing', () => {
|
|
117
|
+
expect(() => new TokowakaClient({ bucketName: 'test-bucket' }, log))
|
|
118
|
+
.to.throw('S3 client is required');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('createFrom', () => {
|
|
123
|
+
it('should create client from context', () => {
|
|
124
|
+
const context = {
|
|
125
|
+
env: { TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket' },
|
|
126
|
+
s3: { s3Client },
|
|
127
|
+
log,
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const createdClient = TokowakaClient.createFrom(context);
|
|
131
|
+
|
|
132
|
+
expect(createdClient).to.be.instanceOf(TokowakaClient);
|
|
133
|
+
expect(context.tokowakaClient).to.equal(createdClient);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should reuse existing client from context', () => {
|
|
137
|
+
const existingClient = new TokowakaClient(
|
|
138
|
+
{ bucketName: 'test-bucket', s3Client },
|
|
139
|
+
log,
|
|
140
|
+
);
|
|
141
|
+
const context = {
|
|
142
|
+
env: { TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket' },
|
|
143
|
+
s3: { s3Client },
|
|
144
|
+
log,
|
|
145
|
+
tokowakaClient: existingClient,
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const createdClient = TokowakaClient.createFrom(context);
|
|
149
|
+
|
|
150
|
+
expect(createdClient).to.equal(existingClient);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
describe('getSupportedOpportunityTypes', () => {
|
|
155
|
+
it('should return list of supported opportunity types', () => {
|
|
156
|
+
const types = client.getSupportedOpportunityTypes();
|
|
157
|
+
|
|
158
|
+
expect(types).to.be.an('array');
|
|
159
|
+
expect(types).to.include('headings');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('registerMapper', () => {
|
|
164
|
+
it('should register a custom mapper', () => {
|
|
165
|
+
class CustomMapper {
|
|
166
|
+
// eslint-disable-next-line class-methods-use-this
|
|
167
|
+
getOpportunityType() {
|
|
168
|
+
return 'custom-type';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// eslint-disable-next-line class-methods-use-this
|
|
172
|
+
requiresPrerender() {
|
|
173
|
+
return false;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// eslint-disable-next-line class-methods-use-this
|
|
177
|
+
suggestionToPatch() {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// eslint-disable-next-line class-methods-use-this
|
|
182
|
+
validateSuggestionData() {
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// eslint-disable-next-line class-methods-use-this
|
|
187
|
+
canDeploy() {
|
|
188
|
+
return { eligible: true };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const customMapper = new CustomMapper();
|
|
193
|
+
client.registerMapper(customMapper);
|
|
194
|
+
|
|
195
|
+
const types = client.getSupportedOpportunityTypes();
|
|
196
|
+
expect(types).to.include('custom-type');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('generateConfig', () => {
|
|
201
|
+
it('should generate config for headings opportunity', () => {
|
|
202
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
203
|
+
|
|
204
|
+
expect(config).to.deep.include({
|
|
205
|
+
siteId: 'site-123',
|
|
206
|
+
baseURL: 'https://example.com',
|
|
207
|
+
version: '1.0',
|
|
208
|
+
tokowakaForceFail: false,
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
expect(config.tokowakaOptimizations).to.have.property('/page1');
|
|
212
|
+
expect(config.tokowakaOptimizations['/page1'].prerender).to.be.true;
|
|
213
|
+
expect(config.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
214
|
+
|
|
215
|
+
const patch = config.tokowakaOptimizations['/page1'].patches[0];
|
|
216
|
+
expect(patch).to.include({
|
|
217
|
+
op: 'replace',
|
|
218
|
+
selector: 'h1',
|
|
219
|
+
value: 'New Heading',
|
|
220
|
+
opportunityId: 'opp-123',
|
|
221
|
+
suggestionId: 'sugg-1',
|
|
222
|
+
prerenderRequired: true,
|
|
223
|
+
});
|
|
224
|
+
expect(patch).to.have.property('lastUpdated');
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('should group suggestions by URL path', () => {
|
|
228
|
+
mockSuggestions = [
|
|
229
|
+
{
|
|
230
|
+
getId: () => 'sugg-1',
|
|
231
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
232
|
+
getData: () => ({
|
|
233
|
+
url: 'https://example.com/page1',
|
|
234
|
+
recommendedAction: 'Page 1 Heading',
|
|
235
|
+
checkType: 'heading-empty',
|
|
236
|
+
transformRules: {
|
|
237
|
+
action: 'replace',
|
|
238
|
+
selector: 'h1',
|
|
239
|
+
},
|
|
240
|
+
}),
|
|
241
|
+
},
|
|
242
|
+
{
|
|
243
|
+
getId: () => 'sugg-2',
|
|
244
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
245
|
+
getData: () => ({
|
|
246
|
+
url: 'https://example.com/page2',
|
|
247
|
+
recommendedAction: 'Page 2 Heading',
|
|
248
|
+
checkType: 'heading-empty',
|
|
249
|
+
transformRules: {
|
|
250
|
+
action: 'replace',
|
|
251
|
+
selector: 'h1',
|
|
252
|
+
},
|
|
253
|
+
}),
|
|
254
|
+
},
|
|
255
|
+
];
|
|
256
|
+
|
|
257
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
258
|
+
|
|
259
|
+
expect(Object.keys(config.tokowakaOptimizations)).to.have.length(2);
|
|
260
|
+
expect(config.tokowakaOptimizations).to.have.property('/page1');
|
|
261
|
+
expect(config.tokowakaOptimizations).to.have.property('/page2');
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should use overrideBaseURL from fetchConfig when available', () => {
|
|
265
|
+
// Set up mockSite with overrideBaseURL
|
|
266
|
+
mockSite.getConfig = () => ({
|
|
267
|
+
getTokowakaConfig: () => ({
|
|
268
|
+
apiKey: 'test-api-key-123',
|
|
269
|
+
cdnProvider: 'cloudfront',
|
|
270
|
+
}),
|
|
271
|
+
getFetchConfig: () => ({
|
|
272
|
+
overrideBaseURL: 'https://override.example.com',
|
|
273
|
+
}),
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
mockSuggestions = [
|
|
277
|
+
{
|
|
278
|
+
getId: () => 'sugg-override',
|
|
279
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
280
|
+
getData: () => ({
|
|
281
|
+
url: '/relative-path',
|
|
282
|
+
recommendedAction: 'Heading',
|
|
283
|
+
checkType: 'heading-empty',
|
|
284
|
+
transformRules: {
|
|
285
|
+
action: 'replace',
|
|
286
|
+
selector: 'h1',
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
},
|
|
290
|
+
];
|
|
291
|
+
|
|
292
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
293
|
+
|
|
294
|
+
expect(Object.keys(config.tokowakaOptimizations)).to.have.length(1);
|
|
295
|
+
expect(config.tokowakaOptimizations).to.have.property('/relative-path');
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('should skip suggestions without URL', () => {
|
|
299
|
+
mockSuggestions = [
|
|
300
|
+
{
|
|
301
|
+
getId: () => 'sugg-1',
|
|
302
|
+
getData: () => ({
|
|
303
|
+
selector: 'h1',
|
|
304
|
+
value: 'Heading without URL',
|
|
305
|
+
}),
|
|
306
|
+
},
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
310
|
+
|
|
311
|
+
expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
|
|
312
|
+
expect(log.warn).to.have.been.calledWith(sinon.match(/does not have a URL/));
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
it('should skip suggestions with invalid URL', () => {
|
|
316
|
+
mockSuggestions = [
|
|
317
|
+
{
|
|
318
|
+
getId: () => 'sugg-1',
|
|
319
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
320
|
+
getData: () => ({
|
|
321
|
+
url: 'http://invalid domain with spaces.com',
|
|
322
|
+
checkType: 'heading-empty',
|
|
323
|
+
recommendedAction: 'Heading',
|
|
324
|
+
transformRules: {
|
|
325
|
+
action: 'replace',
|
|
326
|
+
selector: 'h1',
|
|
327
|
+
},
|
|
328
|
+
}),
|
|
329
|
+
},
|
|
330
|
+
];
|
|
331
|
+
|
|
332
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
333
|
+
|
|
334
|
+
expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
|
|
335
|
+
expect(log.warn).to.have.been.calledWith(sinon.match(/Failed to extract pathname from URL/));
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should skip suggestions with missing required fields', () => {
|
|
339
|
+
mockSuggestions = [
|
|
340
|
+
{
|
|
341
|
+
getId: () => 'sugg-1',
|
|
342
|
+
getData: () => ({
|
|
343
|
+
url: 'https://example.com/page1',
|
|
344
|
+
// Missing required fields
|
|
345
|
+
}),
|
|
346
|
+
},
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
|
|
350
|
+
|
|
351
|
+
expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
|
|
352
|
+
expect(log.warn).to.have.been.calledWith(sinon.match(/cannot be deployed/));
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it('should handle unsupported opportunity types', () => {
|
|
356
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
357
|
+
mockSuggestions = [
|
|
358
|
+
{
|
|
359
|
+
getId: () => 'sugg-1',
|
|
360
|
+
getData: () => ({
|
|
361
|
+
url: 'https://example.com/page1',
|
|
362
|
+
}),
|
|
363
|
+
},
|
|
364
|
+
];
|
|
365
|
+
|
|
366
|
+
expect(() => client.generateConfig(mockSite, mockOpportunity, mockSuggestions))
|
|
367
|
+
.to.throw(/No mapper found for opportunity type: unsupported-type/)
|
|
368
|
+
.with.property('status', 501);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe('uploadConfig', () => {
|
|
373
|
+
it('should upload config to S3', async () => {
|
|
374
|
+
const config = {
|
|
375
|
+
siteId: 'site-123',
|
|
376
|
+
baseURL: 'https://example.com',
|
|
377
|
+
version: '1.0',
|
|
378
|
+
tokowakaForceFail: false,
|
|
379
|
+
tokowakaOptimizations: {},
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
const s3Key = await client.uploadConfig('test-api-key', config);
|
|
383
|
+
|
|
384
|
+
expect(s3Key).to.equal('opportunities/test-api-key');
|
|
385
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
386
|
+
|
|
387
|
+
const command = s3Client.send.firstCall.args[0];
|
|
388
|
+
expect(command.input.Bucket).to.equal('test-bucket');
|
|
389
|
+
expect(command.input.Key).to.equal('opportunities/test-api-key');
|
|
390
|
+
expect(command.input.ContentType).to.equal('application/json');
|
|
391
|
+
expect(JSON.parse(command.input.Body)).to.deep.equal(config);
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('should throw error if apiKey is missing', async () => {
|
|
395
|
+
const config = { siteId: 'site-123' };
|
|
396
|
+
|
|
397
|
+
try {
|
|
398
|
+
await client.uploadConfig('', config);
|
|
399
|
+
expect.fail('Should have thrown error');
|
|
400
|
+
} catch (error) {
|
|
401
|
+
expect(error.message).to.include('Tokowaka API key is required');
|
|
402
|
+
expect(error.status).to.equal(400);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('should throw error if config is empty', async () => {
|
|
407
|
+
try {
|
|
408
|
+
await client.uploadConfig('test-api-key', {});
|
|
409
|
+
expect.fail('Should have thrown error');
|
|
410
|
+
} catch (error) {
|
|
411
|
+
expect(error.message).to.include('Config object is required');
|
|
412
|
+
expect(error.status).to.equal(400);
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('should handle S3 upload failure', async () => {
|
|
417
|
+
s3Client.send.rejects(new Error('Network error'));
|
|
418
|
+
const config = { siteId: 'site-123', tokowakaOptimizations: {} };
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
await client.uploadConfig('test-api-key', config);
|
|
422
|
+
expect.fail('Should have thrown error');
|
|
423
|
+
} catch (error) {
|
|
424
|
+
expect(error.message).to.include('S3 upload failed');
|
|
425
|
+
expect(error.status).to.equal(500);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('fetchConfig', () => {
|
|
431
|
+
it('should fetch existing config from S3', async () => {
|
|
432
|
+
const existingConfig = {
|
|
433
|
+
siteId: 'site-123',
|
|
434
|
+
baseURL: 'https://example.com',
|
|
435
|
+
version: '1.0',
|
|
436
|
+
tokowakaForceFail: false,
|
|
437
|
+
tokowakaOptimizations: {
|
|
438
|
+
'/page1': {
|
|
439
|
+
prerender: true,
|
|
440
|
+
patches: [
|
|
441
|
+
{
|
|
442
|
+
op: 'replace',
|
|
443
|
+
selector: 'h1',
|
|
444
|
+
value: 'Old Heading',
|
|
445
|
+
opportunityId: 'opp-123',
|
|
446
|
+
suggestionId: 'sugg-1',
|
|
447
|
+
prerenderRequired: true,
|
|
448
|
+
lastUpdated: 1234567890,
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
};
|
|
454
|
+
|
|
455
|
+
s3Client.send.resolves({
|
|
456
|
+
Body: {
|
|
457
|
+
transformToString: async () => JSON.stringify(existingConfig),
|
|
458
|
+
},
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
const config = await client.fetchConfig('test-api-key');
|
|
462
|
+
|
|
463
|
+
expect(config).to.deep.equal(existingConfig);
|
|
464
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
465
|
+
|
|
466
|
+
const command = s3Client.send.firstCall.args[0];
|
|
467
|
+
expect(command.input.Bucket).to.equal('test-bucket');
|
|
468
|
+
expect(command.input.Key).to.equal('opportunities/test-api-key');
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
it('should return null if config does not exist', async () => {
|
|
472
|
+
const noSuchKeyError = new Error('NoSuchKey');
|
|
473
|
+
noSuchKeyError.name = 'NoSuchKey';
|
|
474
|
+
s3Client.send.rejects(noSuchKeyError);
|
|
475
|
+
|
|
476
|
+
const config = await client.fetchConfig('test-api-key');
|
|
477
|
+
|
|
478
|
+
expect(config).to.be.null;
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
it('should return null if S3 returns NoSuchKey error code', async () => {
|
|
482
|
+
const noSuchKeyError = new Error('The specified key does not exist');
|
|
483
|
+
noSuchKeyError.Code = 'NoSuchKey';
|
|
484
|
+
s3Client.send.rejects(noSuchKeyError);
|
|
485
|
+
|
|
486
|
+
const config = await client.fetchConfig('test-api-key');
|
|
487
|
+
|
|
488
|
+
expect(config).to.be.null;
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('should throw error if apiKey is missing', async () => {
|
|
492
|
+
try {
|
|
493
|
+
await client.fetchConfig('');
|
|
494
|
+
expect.fail('Should have thrown error');
|
|
495
|
+
} catch (error) {
|
|
496
|
+
expect(error.message).to.include('Tokowaka API key is required');
|
|
497
|
+
expect(error.status).to.equal(400);
|
|
498
|
+
}
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it('should handle S3 fetch failure', async () => {
|
|
502
|
+
s3Client.send.rejects(new Error('Network error'));
|
|
503
|
+
|
|
504
|
+
try {
|
|
505
|
+
await client.fetchConfig('test-api-key');
|
|
506
|
+
expect.fail('Should have thrown error');
|
|
507
|
+
} catch (error) {
|
|
508
|
+
expect(error.message).to.include('S3 fetch failed');
|
|
509
|
+
expect(error.status).to.equal(500);
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
describe('mergeConfigs', () => {
|
|
515
|
+
let existingConfig;
|
|
516
|
+
let newConfig;
|
|
517
|
+
|
|
518
|
+
beforeEach(() => {
|
|
519
|
+
existingConfig = {
|
|
520
|
+
siteId: 'site-123',
|
|
521
|
+
baseURL: 'https://example.com',
|
|
522
|
+
version: '1.0',
|
|
523
|
+
tokowakaForceFail: false,
|
|
524
|
+
tokowakaOptimizations: {
|
|
525
|
+
'/page1': {
|
|
526
|
+
prerender: true,
|
|
527
|
+
patches: [
|
|
528
|
+
{
|
|
529
|
+
op: 'replace',
|
|
530
|
+
selector: 'h1',
|
|
531
|
+
value: 'Old Heading',
|
|
532
|
+
opportunityId: 'opp-123',
|
|
533
|
+
suggestionId: 'sugg-1',
|
|
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
|
+
],
|
|
547
|
+
},
|
|
548
|
+
},
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
newConfig = {
|
|
552
|
+
siteId: 'site-123',
|
|
553
|
+
baseURL: 'https://example.com',
|
|
554
|
+
version: '1.0',
|
|
555
|
+
tokowakaForceFail: false,
|
|
556
|
+
tokowakaOptimizations: {
|
|
557
|
+
'/page1': {
|
|
558
|
+
prerender: true,
|
|
559
|
+
patches: [
|
|
560
|
+
{
|
|
561
|
+
op: 'replace',
|
|
562
|
+
selector: 'h1',
|
|
563
|
+
value: 'Updated Heading',
|
|
564
|
+
opportunityId: 'opp-123',
|
|
565
|
+
suggestionId: 'sugg-1',
|
|
566
|
+
prerenderRequired: true,
|
|
567
|
+
lastUpdated: 1234567900,
|
|
568
|
+
},
|
|
569
|
+
],
|
|
570
|
+
},
|
|
571
|
+
},
|
|
572
|
+
};
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
it('should return new config if existing config is null', () => {
|
|
576
|
+
const merged = client.mergeConfigs(null, newConfig);
|
|
577
|
+
|
|
578
|
+
expect(merged).to.deep.equal(newConfig);
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it('should update existing patch with same opportunityId and suggestionId', () => {
|
|
582
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
583
|
+
|
|
584
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
585
|
+
|
|
586
|
+
// First patch should be updated
|
|
587
|
+
const updatedPatch = merged.tokowakaOptimizations['/page1'].patches[0];
|
|
588
|
+
expect(updatedPatch.value).to.equal('Updated Heading');
|
|
589
|
+
expect(updatedPatch.lastUpdated).to.equal(1234567900);
|
|
590
|
+
|
|
591
|
+
// Second patch should remain unchanged
|
|
592
|
+
const unchangedPatch = merged.tokowakaOptimizations['/page1'].patches[1];
|
|
593
|
+
expect(unchangedPatch.value).to.equal('Old Subtitle');
|
|
594
|
+
expect(unchangedPatch.opportunityId).to.equal('opp-456');
|
|
595
|
+
});
|
|
596
|
+
|
|
597
|
+
it('should add new patch if opportunityId and suggestionId do not exist', () => {
|
|
598
|
+
newConfig.tokowakaOptimizations['/page1'].patches.push({
|
|
599
|
+
op: 'replace',
|
|
600
|
+
selector: 'h3',
|
|
601
|
+
value: 'New Section Title',
|
|
602
|
+
opportunityId: 'opp-789',
|
|
603
|
+
suggestionId: 'sugg-3',
|
|
604
|
+
prerenderRequired: true,
|
|
605
|
+
lastUpdated: 1234567900,
|
|
606
|
+
});
|
|
607
|
+
|
|
608
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
609
|
+
|
|
610
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(3);
|
|
611
|
+
|
|
612
|
+
// New patch should be added at the end
|
|
613
|
+
const newPatch = merged.tokowakaOptimizations['/page1'].patches[2];
|
|
614
|
+
expect(newPatch.value).to.equal('New Section Title');
|
|
615
|
+
expect(newPatch.opportunityId).to.equal('opp-789');
|
|
616
|
+
expect(newPatch.suggestionId).to.equal('sugg-3');
|
|
617
|
+
});
|
|
618
|
+
|
|
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
|
+
it('should update config metadata from new config', () => {
|
|
667
|
+
newConfig.version = '2.0';
|
|
668
|
+
newConfig.tokowakaForceFail = true;
|
|
669
|
+
|
|
670
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
671
|
+
|
|
672
|
+
expect(merged.version).to.equal('2.0');
|
|
673
|
+
expect(merged.tokowakaForceFail).to.equal(true);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
it('should handle empty patches array in existing config', () => {
|
|
677
|
+
existingConfig.tokowakaOptimizations['/page1'].patches = [];
|
|
678
|
+
|
|
679
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
680
|
+
|
|
681
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(1);
|
|
682
|
+
expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Updated Heading');
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
it('should handle empty patches array in new config', () => {
|
|
686
|
+
newConfig.tokowakaOptimizations['/page1'].patches = [];
|
|
687
|
+
|
|
688
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
689
|
+
|
|
690
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
691
|
+
expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Old Heading');
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
it('should handle missing patches property in existing config', () => {
|
|
695
|
+
delete existingConfig.tokowakaOptimizations['/page1'].patches;
|
|
696
|
+
|
|
697
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
698
|
+
|
|
699
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(1);
|
|
700
|
+
expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Updated Heading');
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
it('should handle missing patches property in new config', () => {
|
|
704
|
+
delete newConfig.tokowakaOptimizations['/page1'].patches;
|
|
705
|
+
|
|
706
|
+
const merged = client.mergeConfigs(existingConfig, newConfig);
|
|
707
|
+
|
|
708
|
+
expect(merged.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
709
|
+
expect(merged.tokowakaOptimizations['/page1'].patches[0].value).to.equal('Old Heading');
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
|
|
713
|
+
describe('deploySuggestions', () => {
|
|
714
|
+
beforeEach(() => {
|
|
715
|
+
// Stub CDN invalidation for deploy tests
|
|
716
|
+
sinon.stub(client, 'invalidateCdnCache').resolves({
|
|
717
|
+
status: 'success',
|
|
718
|
+
provider: 'cloudfront',
|
|
719
|
+
invalidationId: 'I123',
|
|
720
|
+
});
|
|
721
|
+
// Stub fetchConfig to return null by default (no existing config)
|
|
722
|
+
sinon.stub(client, 'fetchConfig').resolves(null);
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
it('should deploy suggestions successfully', async () => {
|
|
726
|
+
const result = await client.deploySuggestions(
|
|
727
|
+
mockSite,
|
|
728
|
+
mockOpportunity,
|
|
729
|
+
mockSuggestions,
|
|
730
|
+
);
|
|
731
|
+
|
|
732
|
+
expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
|
|
733
|
+
expect(s3Client.send).to.have.been.calledOnce;
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
it('should throw error if site does not have Tokowaka API key', async () => {
|
|
737
|
+
mockSite.getConfig = () => ({
|
|
738
|
+
getTokowakaConfig: () => ({}),
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
await client.deploySuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
743
|
+
expect.fail('Should have thrown error');
|
|
744
|
+
} catch (error) {
|
|
745
|
+
expect(error.message).to.include('Tokowaka API key configured');
|
|
746
|
+
expect(error.status).to.equal(400);
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it('should handle suggestions that are not eligible for deployment', async () => {
|
|
751
|
+
// Create suggestions with different checkTypes
|
|
752
|
+
mockSuggestions = [
|
|
753
|
+
{
|
|
754
|
+
getId: () => 'sugg-1',
|
|
755
|
+
getData: () => ({
|
|
756
|
+
url: 'https://example.com/page1',
|
|
757
|
+
recommendedAction: 'New Heading',
|
|
758
|
+
checkType: 'heading-missing', // Not eligible (wrong checkType name)
|
|
759
|
+
}),
|
|
760
|
+
},
|
|
761
|
+
{
|
|
762
|
+
getId: () => 'sugg-2',
|
|
763
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
764
|
+
getData: () => ({
|
|
765
|
+
url: 'https://example.com/page1',
|
|
766
|
+
recommendedAction: 'New Subtitle',
|
|
767
|
+
checkType: 'heading-empty', // Eligible
|
|
768
|
+
transformRules: {
|
|
769
|
+
action: 'replace',
|
|
770
|
+
selector: 'h2',
|
|
771
|
+
},
|
|
772
|
+
}),
|
|
773
|
+
},
|
|
774
|
+
];
|
|
775
|
+
|
|
776
|
+
const result = await client.deploySuggestions(
|
|
777
|
+
mockSite,
|
|
778
|
+
mockOpportunity,
|
|
779
|
+
mockSuggestions,
|
|
780
|
+
);
|
|
781
|
+
|
|
782
|
+
expect(result.succeededSuggestions).to.have.length(1);
|
|
783
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
784
|
+
expect(result.failedSuggestions[0].reason).to.include('can be deployed');
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('should return early when no eligible suggestions', async () => {
|
|
788
|
+
// All suggestions are ineligible
|
|
789
|
+
mockSuggestions = [
|
|
790
|
+
{
|
|
791
|
+
getId: () => 'sugg-1',
|
|
792
|
+
getData: () => ({
|
|
793
|
+
url: 'https://example.com/page1',
|
|
794
|
+
recommendedAction: 'New Heading',
|
|
795
|
+
checkType: 'heading-missing', // Wrong checkType name, not eligible
|
|
796
|
+
}),
|
|
797
|
+
},
|
|
798
|
+
];
|
|
799
|
+
|
|
800
|
+
const result = await client.deploySuggestions(
|
|
801
|
+
mockSite,
|
|
802
|
+
mockOpportunity,
|
|
803
|
+
mockSuggestions,
|
|
804
|
+
);
|
|
805
|
+
|
|
806
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
807
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
808
|
+
expect(log.warn).to.have.been.calledWith('No eligible suggestions to deploy');
|
|
809
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it('should return early when suggestions pass eligibility but fail during config generation', async () => {
|
|
813
|
+
// Suggestions pass canDeploy but have no URL (caught in generateConfig)
|
|
814
|
+
mockSuggestions = [
|
|
815
|
+
{
|
|
816
|
+
getId: () => 'sugg-1',
|
|
817
|
+
getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
|
|
818
|
+
getData: () => ({
|
|
819
|
+
// Missing URL
|
|
820
|
+
recommendedAction: 'New Heading',
|
|
821
|
+
checkType: 'heading-empty',
|
|
822
|
+
transformRules: {
|
|
823
|
+
action: 'replace',
|
|
824
|
+
selector: 'h1',
|
|
825
|
+
},
|
|
826
|
+
}),
|
|
827
|
+
},
|
|
828
|
+
];
|
|
829
|
+
|
|
830
|
+
const result = await client.deploySuggestions(
|
|
831
|
+
mockSite,
|
|
832
|
+
mockOpportunity,
|
|
833
|
+
mockSuggestions,
|
|
834
|
+
);
|
|
835
|
+
|
|
836
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
837
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
838
|
+
expect(s3Client.send).to.not.have.been.called;
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
it('should throw error for unsupported opportunity type', async () => {
|
|
842
|
+
mockOpportunity.getType = () => 'unsupported-type';
|
|
843
|
+
|
|
844
|
+
try {
|
|
845
|
+
await client.deploySuggestions(mockSite, mockOpportunity, mockSuggestions);
|
|
846
|
+
expect.fail('Should have thrown error');
|
|
847
|
+
} catch (error) {
|
|
848
|
+
expect(error.message).to.include('No mapper found for opportunity type: unsupported-type');
|
|
849
|
+
expect(error.message).to.include('Supported types:');
|
|
850
|
+
expect(error.status).to.equal(501);
|
|
851
|
+
}
|
|
852
|
+
});
|
|
853
|
+
|
|
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
|
+
it('should fetch existing config and merge when deploying', async () => {
|
|
885
|
+
const existingConfig = {
|
|
886
|
+
siteId: 'site-123',
|
|
887
|
+
baseURL: 'https://example.com',
|
|
888
|
+
version: '1.0',
|
|
889
|
+
tokowakaForceFail: false,
|
|
890
|
+
tokowakaOptimizations: {
|
|
891
|
+
'/page1': {
|
|
892
|
+
prerender: true,
|
|
893
|
+
patches: [
|
|
894
|
+
{
|
|
895
|
+
op: 'replace',
|
|
896
|
+
selector: 'h3',
|
|
897
|
+
value: 'Existing Heading',
|
|
898
|
+
opportunityId: 'opp-999',
|
|
899
|
+
suggestionId: 'sugg-999',
|
|
900
|
+
prerenderRequired: true,
|
|
901
|
+
lastUpdated: 1234567890,
|
|
902
|
+
},
|
|
903
|
+
],
|
|
904
|
+
},
|
|
905
|
+
},
|
|
906
|
+
};
|
|
907
|
+
|
|
908
|
+
client.fetchConfig.resolves(existingConfig);
|
|
909
|
+
|
|
910
|
+
const result = await client.deploySuggestions(
|
|
911
|
+
mockSite,
|
|
912
|
+
mockOpportunity,
|
|
913
|
+
mockSuggestions,
|
|
914
|
+
);
|
|
915
|
+
|
|
916
|
+
expect(client.fetchConfig).to.have.been.calledWith('test-api-key-123');
|
|
917
|
+
expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
|
|
918
|
+
|
|
919
|
+
// Verify the uploaded config contains both existing and new patches
|
|
920
|
+
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
921
|
+
expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(3);
|
|
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);
|
|
939
|
+
});
|
|
940
|
+
|
|
941
|
+
it('should update existing patch when deploying same opportunityId and suggestionId', async () => {
|
|
942
|
+
const existingConfig = {
|
|
943
|
+
siteId: 'site-123',
|
|
944
|
+
baseURL: 'https://example.com',
|
|
945
|
+
version: '1.0',
|
|
946
|
+
tokowakaForceFail: false,
|
|
947
|
+
tokowakaOptimizations: {
|
|
948
|
+
'/page1': {
|
|
949
|
+
prerender: true,
|
|
950
|
+
patches: [
|
|
951
|
+
{
|
|
952
|
+
op: 'replace',
|
|
953
|
+
selector: 'h1',
|
|
954
|
+
value: 'Old Heading Value',
|
|
955
|
+
opportunityId: 'opp-123',
|
|
956
|
+
suggestionId: 'sugg-1',
|
|
957
|
+
prerenderRequired: true,
|
|
958
|
+
lastUpdated: 1234567890,
|
|
959
|
+
},
|
|
960
|
+
],
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
client.fetchConfig.resolves(existingConfig);
|
|
966
|
+
|
|
967
|
+
const result = await client.deploySuggestions(
|
|
968
|
+
mockSite,
|
|
969
|
+
mockOpportunity,
|
|
970
|
+
mockSuggestions,
|
|
971
|
+
);
|
|
972
|
+
|
|
973
|
+
expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
|
|
974
|
+
|
|
975
|
+
// Verify the patch was updated, not duplicated
|
|
976
|
+
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
977
|
+
expect(uploadedConfig.tokowakaOptimizations['/page1'].patches).to.have.length(2);
|
|
978
|
+
|
|
979
|
+
// First patch should be updated with new value
|
|
980
|
+
const updatedPatch = uploadedConfig.tokowakaOptimizations['/page1'].patches[0];
|
|
981
|
+
expect(updatedPatch.value).to.equal('New Heading');
|
|
982
|
+
expect(updatedPatch.opportunityId).to.equal('opp-123');
|
|
983
|
+
expect(updatedPatch.suggestionId).to.equal('sugg-1');
|
|
984
|
+
expect(updatedPatch.lastUpdated).to.be.greaterThan(1234567890);
|
|
985
|
+
});
|
|
986
|
+
|
|
987
|
+
it('should preserve existing URL paths when merging', async () => {
|
|
988
|
+
const existingConfig = {
|
|
989
|
+
siteId: 'site-123',
|
|
990
|
+
baseURL: 'https://example.com',
|
|
991
|
+
version: '1.0',
|
|
992
|
+
tokowakaForceFail: false,
|
|
993
|
+
tokowakaOptimizations: {
|
|
994
|
+
'/page1': {
|
|
995
|
+
prerender: true,
|
|
996
|
+
patches: [
|
|
997
|
+
{
|
|
998
|
+
op: 'replace',
|
|
999
|
+
selector: 'h1',
|
|
1000
|
+
value: 'Page 1 Heading',
|
|
1001
|
+
opportunityId: 'opp-123',
|
|
1002
|
+
suggestionId: 'sugg-1',
|
|
1003
|
+
prerenderRequired: true,
|
|
1004
|
+
lastUpdated: 1234567890,
|
|
1005
|
+
},
|
|
1006
|
+
],
|
|
1007
|
+
},
|
|
1008
|
+
'/other-page': {
|
|
1009
|
+
prerender: false,
|
|
1010
|
+
patches: [
|
|
1011
|
+
{
|
|
1012
|
+
op: 'replace',
|
|
1013
|
+
selector: 'h1',
|
|
1014
|
+
value: 'Other Page Heading',
|
|
1015
|
+
opportunityId: 'opp-888',
|
|
1016
|
+
suggestionId: 'sugg-888',
|
|
1017
|
+
prerenderRequired: false,
|
|
1018
|
+
lastUpdated: 1234567890,
|
|
1019
|
+
},
|
|
1020
|
+
],
|
|
1021
|
+
},
|
|
1022
|
+
},
|
|
1023
|
+
};
|
|
1024
|
+
|
|
1025
|
+
client.fetchConfig.resolves(existingConfig);
|
|
1026
|
+
|
|
1027
|
+
const result = await client.deploySuggestions(
|
|
1028
|
+
mockSite,
|
|
1029
|
+
mockOpportunity,
|
|
1030
|
+
mockSuggestions,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
expect(result).to.have.property('s3Path', 'opportunities/test-api-key-123');
|
|
1034
|
+
|
|
1035
|
+
// Verify existing URL paths are preserved
|
|
1036
|
+
const uploadedConfig = JSON.parse(s3Client.send.firstCall.args[0].input.Body);
|
|
1037
|
+
expect(uploadedConfig.tokowakaOptimizations).to.have.property('/page1');
|
|
1038
|
+
expect(uploadedConfig.tokowakaOptimizations).to.have.property('/other-page');
|
|
1039
|
+
expect(uploadedConfig.tokowakaOptimizations['/other-page'].patches[0].value)
|
|
1040
|
+
.to.equal('Other Page Heading');
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
|
|
1044
|
+
describe('invalidateCdnCache', () => {
|
|
1045
|
+
let mockCdnClient;
|
|
1046
|
+
|
|
1047
|
+
beforeEach(() => {
|
|
1048
|
+
mockCdnClient = {
|
|
1049
|
+
invalidateCache: sinon.stub().resolves({
|
|
1050
|
+
status: 'success',
|
|
1051
|
+
provider: 'cloudfront',
|
|
1052
|
+
invalidationId: 'I123',
|
|
1053
|
+
}),
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
sinon.stub(client.cdnClientRegistry, 'getClient').returns(mockCdnClient);
|
|
1057
|
+
});
|
|
1058
|
+
|
|
1059
|
+
it('should invalidate CDN cache successfully', async () => {
|
|
1060
|
+
const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
|
|
1061
|
+
|
|
1062
|
+
expect(result).to.deep.equal({
|
|
1063
|
+
status: 'success',
|
|
1064
|
+
provider: 'cloudfront',
|
|
1065
|
+
invalidationId: 'I123',
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
expect(mockCdnClient.invalidateCache).to.have.been.calledWith([
|
|
1069
|
+
'/opportunities/test-api-key',
|
|
1070
|
+
]);
|
|
1071
|
+
expect(log.debug).to.have.been.calledWith(sinon.match(/Invalidating CDN cache/));
|
|
1072
|
+
expect(log.info).to.have.been.calledWith(sinon.match(/CDN cache invalidation completed/));
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
it('should return null if no CDN configuration', async () => {
|
|
1076
|
+
try {
|
|
1077
|
+
await client.invalidateCdnCache('', 'cloudfront');
|
|
1078
|
+
expect.fail('Should have thrown error');
|
|
1079
|
+
} catch (error) {
|
|
1080
|
+
expect(error.message).to.equal('Tokowaka API key and provider are required');
|
|
1081
|
+
expect(error.status).to.equal(400);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
|
|
1085
|
+
it('should return null if CDN config is empty', async () => {
|
|
1086
|
+
try {
|
|
1087
|
+
await client.invalidateCdnCache('test-api-key', '');
|
|
1088
|
+
expect.fail('Should have thrown error');
|
|
1089
|
+
} catch (error) {
|
|
1090
|
+
expect(error.message).to.equal('Tokowaka API key and provider are required');
|
|
1091
|
+
expect(error.status).to.equal(400);
|
|
1092
|
+
}
|
|
1093
|
+
});
|
|
1094
|
+
|
|
1095
|
+
it('should return null if CDN provider is missing', async () => {
|
|
1096
|
+
try {
|
|
1097
|
+
await client.invalidateCdnCache('test-api-key', null);
|
|
1098
|
+
expect.fail('Should have thrown error');
|
|
1099
|
+
} catch (error) {
|
|
1100
|
+
expect(error.message).to.equal('Tokowaka API key and provider are required');
|
|
1101
|
+
expect(error.status).to.equal(400);
|
|
1102
|
+
}
|
|
1103
|
+
});
|
|
1104
|
+
|
|
1105
|
+
it('should return null if CDN config is missing', async () => {
|
|
1106
|
+
try {
|
|
1107
|
+
await client.invalidateCdnCache(null, 'cloudfront');
|
|
1108
|
+
expect.fail('Should have thrown error');
|
|
1109
|
+
} catch (error) {
|
|
1110
|
+
expect(error.message).to.equal('Tokowaka API key and provider are required');
|
|
1111
|
+
expect(error.status).to.equal(400);
|
|
1112
|
+
}
|
|
1113
|
+
});
|
|
1114
|
+
|
|
1115
|
+
it('should return null if no CDN client available', async () => {
|
|
1116
|
+
client.cdnClientRegistry.getClient.returns(null);
|
|
1117
|
+
|
|
1118
|
+
const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
|
|
1119
|
+
|
|
1120
|
+
expect(result).to.deep.equal({
|
|
1121
|
+
status: 'error',
|
|
1122
|
+
provider: 'cloudfront',
|
|
1123
|
+
message: 'No CDN client available for provider: cloudfront',
|
|
1124
|
+
});
|
|
1125
|
+
expect(log.error).to.have.been.calledWith(sinon.match(/Failed to invalidate Tokowaka CDN cache/));
|
|
1126
|
+
});
|
|
1127
|
+
|
|
1128
|
+
it('should return error object if CDN invalidation fails', async () => {
|
|
1129
|
+
mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
|
|
1130
|
+
|
|
1131
|
+
const result = await client.invalidateCdnCache('test-api-key', 'cloudfront');
|
|
1132
|
+
|
|
1133
|
+
expect(result).to.deep.equal({
|
|
1134
|
+
status: 'error',
|
|
1135
|
+
provider: 'cloudfront',
|
|
1136
|
+
message: 'CDN API error',
|
|
1137
|
+
});
|
|
1138
|
+
|
|
1139
|
+
expect(log.error).to.have.been.calledWith(sinon.match(/Failed to invalidate Tokowaka CDN cache/));
|
|
1140
|
+
});
|
|
1141
|
+
});
|
|
1142
|
+
});
|