@adobe/spacecat-shared-tokowaka-client 1.12.3 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/package.json +3 -3
- package/src/index.js +215 -2
- package/test/index.test.js +482 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
## [@adobe/spacecat-shared-tokowaka-client-v1.13.1](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.13.0...@adobe/spacecat-shared-tokowaka-client-v1.13.1) (2026-04-04)
|
|
2
|
+
|
|
3
|
+
### Bug Fixes
|
|
4
|
+
|
|
5
|
+
* **deps:** update external fixes ([#1506](https://github.com/adobe/spacecat-shared/issues/1506)) ([a4516f6](https://github.com/adobe/spacecat-shared/commit/a4516f68dcb8b2efffc2a0c1e2ec2770347c163d))
|
|
6
|
+
|
|
7
|
+
## [@adobe/spacecat-shared-tokowaka-client-v1.13.0](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.12.3...@adobe/spacecat-shared-tokowaka-client-v1.13.0) (2026-04-01)
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* experimentation engine ([#1446](https://github.com/adobe/spacecat-shared/issues/1446)) ([44bff63](https://github.com/adobe/spacecat-shared/commit/44bff6350c18db58d8fbffde8de05074269ec969))
|
|
12
|
+
|
|
1
13
|
## [@adobe/spacecat-shared-tokowaka-client-v1.12.3](https://github.com/adobe/spacecat-shared/compare/@adobe/spacecat-shared-tokowaka-client-v1.12.2...@adobe/spacecat-shared-tokowaka-client-v1.12.3) (2026-03-28)
|
|
2
14
|
|
|
3
15
|
### Bug Fixes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adobe/spacecat-shared-tokowaka-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
4
4
|
"description": "Tokowaka Client for SpaceCat - Edge optimization config management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@adobe/spacecat-shared-utils": "1.81.1",
|
|
38
|
-
"@aws-sdk/client-cloudfront": "3.
|
|
39
|
-
"@aws-sdk/client-s3": "3.
|
|
38
|
+
"@aws-sdk/client-cloudfront": "3.1024.0",
|
|
39
|
+
"@aws-sdk/client-s3": "3.1024.0",
|
|
40
40
|
"hast-util-from-html": "2.0.3",
|
|
41
41
|
"mdast-util-from-markdown": "2.0.3",
|
|
42
42
|
"mdast-util-to-hast": "13.2.1",
|
package/src/index.js
CHANGED
|
@@ -34,6 +34,11 @@ const HTTP_BAD_REQUEST = 400;
|
|
|
34
34
|
const HTTP_INTERNAL_SERVER_ERROR = 500;
|
|
35
35
|
const HTTP_NOT_IMPLEMENTED = 501;
|
|
36
36
|
|
|
37
|
+
/** Matches SpaceCat API eligibility for edge deploy (non-domain-wide). */
|
|
38
|
+
function isEdgeDeployableSuggestionStatus(status) {
|
|
39
|
+
return status === 'NEW' || status === 'PENDING_VALIDATION';
|
|
40
|
+
}
|
|
41
|
+
|
|
37
42
|
/**
|
|
38
43
|
* Tokowaka Client - Manages edge optimization configurations
|
|
39
44
|
*/
|
|
@@ -54,11 +59,10 @@ class TokowakaClient {
|
|
|
54
59
|
return context.tokowakaClient;
|
|
55
60
|
}
|
|
56
61
|
|
|
57
|
-
// s3ClientWrapper puts s3Client at context.s3.s3Client, so check both locations
|
|
58
62
|
const client = new TokowakaClient({
|
|
59
63
|
bucketName,
|
|
60
64
|
previewBucketName,
|
|
61
|
-
s3Client: s3?.s3Client,
|
|
65
|
+
s3Client: s3?.s3Client ?? context.s3Client,
|
|
62
66
|
env,
|
|
63
67
|
}, log);
|
|
64
68
|
context.tokowakaClient = client;
|
|
@@ -1197,6 +1201,215 @@ class TokowakaClient {
|
|
|
1197
1201
|
);
|
|
1198
1202
|
/* c8 ignore stop */
|
|
1199
1203
|
}
|
|
1204
|
+
|
|
1205
|
+
/**
|
|
1206
|
+
* Deploys suggestions to edge, handling both regular and domain-wide suggestions.
|
|
1207
|
+
*
|
|
1208
|
+
* Regular suggestions are deployed via deploySuggestions(). Domain-wide suggestions
|
|
1209
|
+
* update the site metaconfig's prerender allowList. Suggestions covered by domain-wide
|
|
1210
|
+
* patterns (in the same batch or across the whole opportunity) are automatically marked.
|
|
1211
|
+
*
|
|
1212
|
+
* @param {Object} params
|
|
1213
|
+
* @param {Object} params.site - Site entity
|
|
1214
|
+
* @param {Object} params.opportunity - Opportunity entity
|
|
1215
|
+
* @param {Array} params.targetSuggestions - Suggestions selected for this deployment
|
|
1216
|
+
* @param {Array} params.allSuggestions - All suggestions for the opportunity
|
|
1217
|
+
* @param {string} [params.updatedBy='edge-deploy'] - Value for updatedBy on saved suggestions
|
|
1218
|
+
* @returns {Promise<Object>} { succeededSuggestions, failedSuggestions, coveredSuggestions }
|
|
1219
|
+
* - succeededSuggestions: suggestion entities that were deployed
|
|
1220
|
+
* - failedSuggestions: { suggestion, reason } items that couldn't be deployed
|
|
1221
|
+
* - coveredSuggestions: suggestion entities auto-marked via domain-wide patterns
|
|
1222
|
+
*/
|
|
1223
|
+
async deployToEdge({
|
|
1224
|
+
site,
|
|
1225
|
+
opportunity,
|
|
1226
|
+
targetSuggestions,
|
|
1227
|
+
allSuggestions,
|
|
1228
|
+
updatedBy = 'edge-deploy',
|
|
1229
|
+
}) {
|
|
1230
|
+
const validSuggestions = [];
|
|
1231
|
+
const domainWideSuggestions = [];
|
|
1232
|
+
|
|
1233
|
+
targetSuggestions.forEach((suggestion) => {
|
|
1234
|
+
const data = suggestion.getData();
|
|
1235
|
+
if (data?.isDomainWide === true) {
|
|
1236
|
+
const { allowedRegexPatterns } = data;
|
|
1237
|
+
if (Array.isArray(allowedRegexPatterns) && allowedRegexPatterns.length > 0) {
|
|
1238
|
+
domainWideSuggestions.push({ suggestion, allowedRegexPatterns });
|
|
1239
|
+
}
|
|
1240
|
+
} else if (isEdgeDeployableSuggestionStatus(suggestion.getStatus())) {
|
|
1241
|
+
validSuggestions.push(suggestion);
|
|
1242
|
+
}
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// Filter valid suggestions that are covered by domain-wide patterns in the same batch
|
|
1246
|
+
const skippedInBatch = [];
|
|
1247
|
+
if (domainWideSuggestions.length > 0 && validSuggestions.length > 0) {
|
|
1248
|
+
const allPatterns = [];
|
|
1249
|
+
domainWideSuggestions.forEach(({ allowedRegexPatterns }) => {
|
|
1250
|
+
allowedRegexPatterns.forEach((pattern) => {
|
|
1251
|
+
try {
|
|
1252
|
+
allPatterns.push(new RegExp(pattern));
|
|
1253
|
+
} catch {
|
|
1254
|
+
this.log.warn(`[edge-deploy] Skipping domain-wide pattern ${pattern} - invalid regex`);
|
|
1255
|
+
}
|
|
1256
|
+
});
|
|
1257
|
+
});
|
|
1258
|
+
|
|
1259
|
+
const remaining = [];
|
|
1260
|
+
validSuggestions.forEach((s) => {
|
|
1261
|
+
const url = s.getData()?.url;
|
|
1262
|
+
if (url && allPatterns.some((regex) => regex.test(url))) {
|
|
1263
|
+
skippedInBatch.push(s);
|
|
1264
|
+
this.log.info(`[edge-deploy] Skipping suggestion ${s.getId()} - covered by domain-wide pattern`);
|
|
1265
|
+
} else {
|
|
1266
|
+
remaining.push(s);
|
|
1267
|
+
}
|
|
1268
|
+
});
|
|
1269
|
+
validSuggestions.length = 0;
|
|
1270
|
+
validSuggestions.push(...remaining);
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
let succeededSuggestions = [];
|
|
1274
|
+
const failedSuggestions = [];
|
|
1275
|
+
|
|
1276
|
+
// Deploy regular suggestions via tokowaka
|
|
1277
|
+
if (validSuggestions.length > 0) {
|
|
1278
|
+
try {
|
|
1279
|
+
const result = await this.deploySuggestions(site, opportunity, validSuggestions);
|
|
1280
|
+
const deploymentTimestamp = Date.now();
|
|
1281
|
+
|
|
1282
|
+
succeededSuggestions = await Promise.all(
|
|
1283
|
+
result.succeededSuggestions.map(async (s) => {
|
|
1284
|
+
const currentData = s.getData();
|
|
1285
|
+
const updated = { ...currentData, edgeDeployed: deploymentTimestamp };
|
|
1286
|
+
if (updated.edgeOptimizeStatus === 'STALE') {
|
|
1287
|
+
delete updated.edgeOptimizeStatus;
|
|
1288
|
+
}
|
|
1289
|
+
s.setData(updated);
|
|
1290
|
+
s.setUpdatedBy(updatedBy);
|
|
1291
|
+
await s.save();
|
|
1292
|
+
return s;
|
|
1293
|
+
}),
|
|
1294
|
+
);
|
|
1295
|
+
|
|
1296
|
+
// ineligible suggestions get statusCode 400 — they weren't deployable
|
|
1297
|
+
result.failedSuggestions.forEach((item) => {
|
|
1298
|
+
failedSuggestions.push({ ...item, statusCode: 400 });
|
|
1299
|
+
this.log.info(`[edge-deploy] ${opportunity.getType()} suggestion ${item.suggestion.getId()} is ineligible: ${item.reason}`);
|
|
1300
|
+
});
|
|
1301
|
+
} catch (error) {
|
|
1302
|
+
this.log.error(`[edge-deploy] Error deploying suggestions: ${error.message}`, error);
|
|
1303
|
+
throw error;
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
// Deploy domain-wide suggestions via metaconfig
|
|
1308
|
+
const coveredSuggestions = [];
|
|
1309
|
+
if (domainWideSuggestions.length > 0) {
|
|
1310
|
+
const baseURL = site.getBaseURL();
|
|
1311
|
+
const skippedInBatchIds = new Set(skippedInBatch.map((s) => s.getId()));
|
|
1312
|
+
|
|
1313
|
+
for (const { suggestion, allowedRegexPatterns } of domainWideSuggestions) {
|
|
1314
|
+
// Fix #1: compile + validate regexes before any upload/save so a bad pattern
|
|
1315
|
+
// never causes a suggestion to land in both succeeded and failed lists.
|
|
1316
|
+
const regexPatterns = [];
|
|
1317
|
+
for (const pattern of allowedRegexPatterns) {
|
|
1318
|
+
try {
|
|
1319
|
+
regexPatterns.push(new RegExp(pattern));
|
|
1320
|
+
} catch {
|
|
1321
|
+
this.log.warn(`[edge-deploy] Invalid regex pattern "${pattern}" for domain-wide suggestion ${suggestion.getId()}, skipping`);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
try {
|
|
1326
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1327
|
+
let metaconfig = await this.fetchMetaconfig(baseURL);
|
|
1328
|
+
if (!metaconfig) {
|
|
1329
|
+
metaconfig = { siteId: site.getId() };
|
|
1330
|
+
}
|
|
1331
|
+
metaconfig.prerender = { allowList: allowedRegexPatterns };
|
|
1332
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1333
|
+
await this.uploadMetaconfig(baseURL, metaconfig);
|
|
1334
|
+
|
|
1335
|
+
const deploymentTimestamp = Date.now();
|
|
1336
|
+
suggestion.setData({ ...suggestion.getData(), edgeDeployed: deploymentTimestamp });
|
|
1337
|
+
suggestion.setUpdatedBy(updatedBy);
|
|
1338
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1339
|
+
await suggestion.save();
|
|
1340
|
+
succeededSuggestions.push(suggestion);
|
|
1341
|
+
|
|
1342
|
+
if (regexPatterns.length > 0) {
|
|
1343
|
+
const covered = allSuggestions.filter((s) => {
|
|
1344
|
+
if (s.getId() === suggestion.getId()) return false;
|
|
1345
|
+
if (skippedInBatchIds.has(s.getId())) return false;
|
|
1346
|
+
if (!isEdgeDeployableSuggestionStatus(s.getStatus())) {
|
|
1347
|
+
return false;
|
|
1348
|
+
}
|
|
1349
|
+
if (s.getData()?.isDomainWide === true) {
|
|
1350
|
+
return false;
|
|
1351
|
+
}
|
|
1352
|
+
const url = s.getData()?.url;
|
|
1353
|
+
return url && regexPatterns.some((r) => r.test(url));
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
if (covered.length > 0) {
|
|
1357
|
+
try {
|
|
1358
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1359
|
+
await Promise.all(covered.map(async (cs) => {
|
|
1360
|
+
cs.setData({
|
|
1361
|
+
...cs.getData(),
|
|
1362
|
+
edgeDeployed: deploymentTimestamp,
|
|
1363
|
+
coveredByDomainWide: suggestion.getId(),
|
|
1364
|
+
});
|
|
1365
|
+
cs.setUpdatedBy(updatedBy);
|
|
1366
|
+
return cs.save();
|
|
1367
|
+
}));
|
|
1368
|
+
coveredSuggestions.push(...covered);
|
|
1369
|
+
} catch (coverError) {
|
|
1370
|
+
this.log.warn(`[edge-deploy] Failed to mark covered suggestions for domain-wide ${suggestion.getId()}: ${coverError.message}`);
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
} catch (error) {
|
|
1375
|
+
this.log.error(`[edge-deploy] Error deploying domain-wide suggestion ${suggestion.getId()}: ${error.message}`, error);
|
|
1376
|
+
// statusCode 500 — deploy itself failed, not an eligibility issue
|
|
1377
|
+
failedSuggestions.push({ suggestion, reason: error.message, statusCode: 500 });
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
// Mark same-batch skipped suggestions individually so a single save failure
|
|
1383
|
+
// surfaces as a per-item failure rather than swallowing the whole batch.
|
|
1384
|
+
if (skippedInBatch.length > 0) {
|
|
1385
|
+
const deploymentTimestamp = Date.now();
|
|
1386
|
+
const results = await Promise.allSettled(skippedInBatch.map(async (s) => {
|
|
1387
|
+
s.setData({
|
|
1388
|
+
...s.getData(),
|
|
1389
|
+
edgeDeployed: deploymentTimestamp,
|
|
1390
|
+
coveredByDomainWide: 'same-batch-deployment',
|
|
1391
|
+
skippedInDeployment: true,
|
|
1392
|
+
});
|
|
1393
|
+
s.setUpdatedBy(updatedBy);
|
|
1394
|
+
await s.save();
|
|
1395
|
+
return s;
|
|
1396
|
+
}));
|
|
1397
|
+
|
|
1398
|
+
results.forEach((result, i) => {
|
|
1399
|
+
const s = skippedInBatch[i];
|
|
1400
|
+
if (result.status === 'fulfilled') {
|
|
1401
|
+
succeededSuggestions.push(s);
|
|
1402
|
+
coveredSuggestions.push(s);
|
|
1403
|
+
} else {
|
|
1404
|
+
this.log.warn(`[edge-deploy] Failed to mark same-batch skipped suggestion ${s.getId()}`
|
|
1405
|
+
+ ` - ${result.reason?.message}`);
|
|
1406
|
+
failedSuggestions.push({ suggestion: s, reason: 'Failed to mark as covered by domain-wide', statusCode: 500 });
|
|
1407
|
+
}
|
|
1408
|
+
});
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
return { succeededSuggestions, failedSuggestions, coveredSuggestions };
|
|
1412
|
+
}
|
|
1200
1413
|
}
|
|
1201
1414
|
|
|
1202
1415
|
// Export the client as default and base classes for custom implementations
|
package/test/index.test.js
CHANGED
|
@@ -156,6 +156,22 @@ describe('TokowakaClient', () => {
|
|
|
156
156
|
expect(createdClient.previewBucketName).to.equal('test-preview-bucket');
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
+
it('should create client from context using context.s3Client directly', () => {
|
|
160
|
+
const context = {
|
|
161
|
+
env: {
|
|
162
|
+
TOKOWAKA_SITE_CONFIG_BUCKET: 'test-bucket',
|
|
163
|
+
TOKOWAKA_PREVIEW_BUCKET: 'test-preview-bucket',
|
|
164
|
+
},
|
|
165
|
+
s3Client,
|
|
166
|
+
log,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const createdClient = TokowakaClient.createFrom(context);
|
|
170
|
+
|
|
171
|
+
expect(createdClient).to.be.instanceOf(TokowakaClient);
|
|
172
|
+
expect(context.tokowakaClient).to.equal(createdClient);
|
|
173
|
+
});
|
|
174
|
+
|
|
159
175
|
it('should reuse existing client from context', () => {
|
|
160
176
|
const existingClient = new TokowakaClient(
|
|
161
177
|
{ bucketName: 'test-bucket', s3Client },
|
|
@@ -4253,4 +4269,470 @@ describe('TokowakaClient', () => {
|
|
|
4253
4269
|
});
|
|
4254
4270
|
});
|
|
4255
4271
|
});
|
|
4272
|
+
|
|
4273
|
+
describe('deployToEdge', () => {
|
|
4274
|
+
let deploySuggestionsStub;
|
|
4275
|
+
let fetchMetaconfigStub;
|
|
4276
|
+
let uploadMetaconfigStub;
|
|
4277
|
+
|
|
4278
|
+
function makeSuggestion(id, data, status = 'NEW') {
|
|
4279
|
+
let storedData = { ...data };
|
|
4280
|
+
let storedUpdatedBy;
|
|
4281
|
+
return {
|
|
4282
|
+
getId: () => id,
|
|
4283
|
+
getStatus: () => status,
|
|
4284
|
+
getData: () => storedData,
|
|
4285
|
+
setData: (d) => { storedData = d; },
|
|
4286
|
+
setUpdatedBy: (v) => { storedUpdatedBy = v; },
|
|
4287
|
+
getUpdatedBy: () => storedUpdatedBy,
|
|
4288
|
+
save: sinon.stub().resolves(),
|
|
4289
|
+
};
|
|
4290
|
+
}
|
|
4291
|
+
|
|
4292
|
+
beforeEach(() => {
|
|
4293
|
+
deploySuggestionsStub = sinon.stub(client, 'deploySuggestions');
|
|
4294
|
+
fetchMetaconfigStub = sinon.stub(client, 'fetchMetaconfig');
|
|
4295
|
+
uploadMetaconfigStub = sinon.stub(client, 'uploadMetaconfig').resolves();
|
|
4296
|
+
});
|
|
4297
|
+
|
|
4298
|
+
it('should deploy regular suggestions only', async () => {
|
|
4299
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: { action: 'replace', selector: 'h1' } });
|
|
4300
|
+
const s2 = makeSuggestion('s2', { url: 'https://example.com/page2', transformRules: { action: 'replace', selector: 'h2' } });
|
|
4301
|
+
|
|
4302
|
+
deploySuggestionsStub.resolves({
|
|
4303
|
+
succeededSuggestions: [s1, s2],
|
|
4304
|
+
failedSuggestions: [],
|
|
4305
|
+
});
|
|
4306
|
+
|
|
4307
|
+
const result = await client.deployToEdge({
|
|
4308
|
+
site: mockSite,
|
|
4309
|
+
opportunity: mockOpportunity,
|
|
4310
|
+
targetSuggestions: [s1, s2],
|
|
4311
|
+
allSuggestions: [s1, s2],
|
|
4312
|
+
updatedBy: 'test-user',
|
|
4313
|
+
});
|
|
4314
|
+
|
|
4315
|
+
expect(result.succeededSuggestions).to.have.length(2);
|
|
4316
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
4317
|
+
expect(result.coveredSuggestions).to.have.length(0);
|
|
4318
|
+
expect(s1.save).to.have.been.called;
|
|
4319
|
+
expect(s1.getUpdatedBy()).to.equal('test-user');
|
|
4320
|
+
});
|
|
4321
|
+
|
|
4322
|
+
it('should deploy regular suggestions in PENDING_VALIDATION status', async () => {
|
|
4323
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: {} }, 'PENDING_VALIDATION');
|
|
4324
|
+
|
|
4325
|
+
deploySuggestionsStub.resolves({
|
|
4326
|
+
succeededSuggestions: [s1],
|
|
4327
|
+
failedSuggestions: [],
|
|
4328
|
+
});
|
|
4329
|
+
|
|
4330
|
+
const result = await client.deployToEdge({
|
|
4331
|
+
site: mockSite,
|
|
4332
|
+
opportunity: mockOpportunity,
|
|
4333
|
+
targetSuggestions: [s1],
|
|
4334
|
+
allSuggestions: [s1],
|
|
4335
|
+
updatedBy: 'test-user',
|
|
4336
|
+
});
|
|
4337
|
+
|
|
4338
|
+
expect(result.succeededSuggestions).to.have.length(1);
|
|
4339
|
+
expect(deploySuggestionsStub).to.have.been.calledOnce;
|
|
4340
|
+
expect(s1.save).to.have.been.called;
|
|
4341
|
+
});
|
|
4342
|
+
|
|
4343
|
+
it('should clear edgeOptimizeStatus STALE when deploying', async () => {
|
|
4344
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: {}, edgeOptimizeStatus: 'STALE' });
|
|
4345
|
+
|
|
4346
|
+
deploySuggestionsStub.resolves({
|
|
4347
|
+
succeededSuggestions: [s1],
|
|
4348
|
+
failedSuggestions: [],
|
|
4349
|
+
});
|
|
4350
|
+
|
|
4351
|
+
await client.deployToEdge({
|
|
4352
|
+
site: mockSite,
|
|
4353
|
+
opportunity: mockOpportunity,
|
|
4354
|
+
targetSuggestions: [s1],
|
|
4355
|
+
allSuggestions: [s1],
|
|
4356
|
+
});
|
|
4357
|
+
|
|
4358
|
+
expect(s1.getData()).to.not.have.property('edgeOptimizeStatus');
|
|
4359
|
+
});
|
|
4360
|
+
|
|
4361
|
+
it('should mark ineligible suggestions as failed with statusCode 400', async () => {
|
|
4362
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1' });
|
|
4363
|
+
const ineligible = { suggestion: s1, reason: 'not eligible' };
|
|
4364
|
+
|
|
4365
|
+
deploySuggestionsStub.resolves({
|
|
4366
|
+
succeededSuggestions: [],
|
|
4367
|
+
failedSuggestions: [ineligible],
|
|
4368
|
+
});
|
|
4369
|
+
|
|
4370
|
+
const result = await client.deployToEdge({
|
|
4371
|
+
site: mockSite,
|
|
4372
|
+
opportunity: mockOpportunity,
|
|
4373
|
+
targetSuggestions: [s1],
|
|
4374
|
+
allSuggestions: [s1],
|
|
4375
|
+
});
|
|
4376
|
+
|
|
4377
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
4378
|
+
expect(result.failedSuggestions[0].statusCode).to.equal(400);
|
|
4379
|
+
expect(result.failedSuggestions[0].reason).to.equal('not eligible');
|
|
4380
|
+
});
|
|
4381
|
+
|
|
4382
|
+
it('should re-throw errors from deploySuggestions', async () => {
|
|
4383
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1' });
|
|
4384
|
+
deploySuggestionsStub.rejects(new Error('deploy error'));
|
|
4385
|
+
|
|
4386
|
+
try {
|
|
4387
|
+
await client.deployToEdge({
|
|
4388
|
+
site: mockSite,
|
|
4389
|
+
opportunity: mockOpportunity,
|
|
4390
|
+
targetSuggestions: [s1],
|
|
4391
|
+
allSuggestions: [s1],
|
|
4392
|
+
});
|
|
4393
|
+
expect.fail('Should have thrown');
|
|
4394
|
+
} catch (error) {
|
|
4395
|
+
expect(error.message).to.equal('deploy error');
|
|
4396
|
+
}
|
|
4397
|
+
});
|
|
4398
|
+
|
|
4399
|
+
it('should deploy domain-wide suggestion and update metaconfig', async () => {
|
|
4400
|
+
const dw = makeSuggestion('dw1', {
|
|
4401
|
+
isDomainWide: true,
|
|
4402
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4403
|
+
});
|
|
4404
|
+
|
|
4405
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123', prerender: true });
|
|
4406
|
+
|
|
4407
|
+
const result = await client.deployToEdge({
|
|
4408
|
+
site: mockSite,
|
|
4409
|
+
opportunity: mockOpportunity,
|
|
4410
|
+
targetSuggestions: [dw],
|
|
4411
|
+
allSuggestions: [dw],
|
|
4412
|
+
});
|
|
4413
|
+
|
|
4414
|
+
expect(uploadMetaconfigStub).to.have.been.calledOnce;
|
|
4415
|
+
expect(result.succeededSuggestions).to.include(dw);
|
|
4416
|
+
expect(dw.getData()).to.have.property('edgeDeployed');
|
|
4417
|
+
});
|
|
4418
|
+
|
|
4419
|
+
it('should create new metaconfig when none exists for domain-wide', async () => {
|
|
4420
|
+
const dw = makeSuggestion('dw1', {
|
|
4421
|
+
isDomainWide: true,
|
|
4422
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4423
|
+
});
|
|
4424
|
+
|
|
4425
|
+
fetchMetaconfigStub.resolves(null);
|
|
4426
|
+
|
|
4427
|
+
await client.deployToEdge({
|
|
4428
|
+
site: mockSite,
|
|
4429
|
+
opportunity: mockOpportunity,
|
|
4430
|
+
targetSuggestions: [dw],
|
|
4431
|
+
allSuggestions: [dw],
|
|
4432
|
+
});
|
|
4433
|
+
|
|
4434
|
+
const uploadCall = uploadMetaconfigStub.firstCall.args[1];
|
|
4435
|
+
expect(uploadCall).to.have.property('siteId', 'site-123');
|
|
4436
|
+
});
|
|
4437
|
+
|
|
4438
|
+
it('should mark non-batch suggestions covered by domain-wide patterns', async () => {
|
|
4439
|
+
const dw = makeSuggestion('dw1', {
|
|
4440
|
+
isDomainWide: true,
|
|
4441
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4442
|
+
});
|
|
4443
|
+
const covered = makeSuggestion('covered1', { url: 'https://example.com/page1' });
|
|
4444
|
+
|
|
4445
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4446
|
+
|
|
4447
|
+
const result = await client.deployToEdge({
|
|
4448
|
+
site: mockSite,
|
|
4449
|
+
opportunity: mockOpportunity,
|
|
4450
|
+
targetSuggestions: [dw],
|
|
4451
|
+
allSuggestions: [dw, covered],
|
|
4452
|
+
});
|
|
4453
|
+
|
|
4454
|
+
expect(result.coveredSuggestions).to.include(covered);
|
|
4455
|
+
expect(covered.getData()).to.have.property('coveredByDomainWide', 'dw1');
|
|
4456
|
+
});
|
|
4457
|
+
|
|
4458
|
+
it('should not mark already-domain-wide suggestions as covered', async () => {
|
|
4459
|
+
const dw1 = makeSuggestion('dw1', {
|
|
4460
|
+
isDomainWide: true,
|
|
4461
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4462
|
+
});
|
|
4463
|
+
const dw2 = makeSuggestion('dw2', {
|
|
4464
|
+
isDomainWide: true,
|
|
4465
|
+
allowedRegexPatterns: ['^https://example\\.com/other/.*'],
|
|
4466
|
+
url: 'https://example.com/other/page',
|
|
4467
|
+
});
|
|
4468
|
+
|
|
4469
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4470
|
+
|
|
4471
|
+
const result = await client.deployToEdge({
|
|
4472
|
+
site: mockSite,
|
|
4473
|
+
opportunity: mockOpportunity,
|
|
4474
|
+
targetSuggestions: [dw1],
|
|
4475
|
+
allSuggestions: [dw1, dw2],
|
|
4476
|
+
});
|
|
4477
|
+
|
|
4478
|
+
// dw2 is domain-wide so should not appear in coveredSuggestions
|
|
4479
|
+
expect(result.coveredSuggestions).to.not.include(dw2);
|
|
4480
|
+
});
|
|
4481
|
+
|
|
4482
|
+
it('should not mark non-NEW suggestions as covered', async () => {
|
|
4483
|
+
const dw = makeSuggestion('dw1', {
|
|
4484
|
+
isDomainWide: true,
|
|
4485
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4486
|
+
});
|
|
4487
|
+
const approved = makeSuggestion('ap1', { url: 'https://example.com/page1' }, 'APPROVED');
|
|
4488
|
+
|
|
4489
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4490
|
+
|
|
4491
|
+
const result = await client.deployToEdge({
|
|
4492
|
+
site: mockSite,
|
|
4493
|
+
opportunity: mockOpportunity,
|
|
4494
|
+
targetSuggestions: [dw],
|
|
4495
|
+
allSuggestions: [dw, approved],
|
|
4496
|
+
});
|
|
4497
|
+
|
|
4498
|
+
expect(result.coveredSuggestions).to.not.include(approved);
|
|
4499
|
+
});
|
|
4500
|
+
|
|
4501
|
+
it('should warn but continue when covered-suggestion save fails', async () => {
|
|
4502
|
+
const dw = makeSuggestion('dw1', {
|
|
4503
|
+
isDomainWide: true,
|
|
4504
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4505
|
+
});
|
|
4506
|
+
const covered = makeSuggestion('covered1', { url: 'https://example.com/page1' });
|
|
4507
|
+
covered.save = sinon.stub().rejects(new Error('DB error'));
|
|
4508
|
+
|
|
4509
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4510
|
+
|
|
4511
|
+
const result = await client.deployToEdge({
|
|
4512
|
+
site: mockSite,
|
|
4513
|
+
opportunity: mockOpportunity,
|
|
4514
|
+
targetSuggestions: [dw],
|
|
4515
|
+
allSuggestions: [dw, covered],
|
|
4516
|
+
});
|
|
4517
|
+
|
|
4518
|
+
// domain-wide itself still succeeded
|
|
4519
|
+
expect(result.succeededSuggestions).to.include(dw);
|
|
4520
|
+
// covered is not in either list — the error was swallowed with a warning
|
|
4521
|
+
expect(result.coveredSuggestions).to.not.include(covered);
|
|
4522
|
+
expect(log.warn).to.have.been.called;
|
|
4523
|
+
});
|
|
4524
|
+
|
|
4525
|
+
it('should mark domain-wide as failed with statusCode 500 when metaconfig upload fails', async () => {
|
|
4526
|
+
const dw = makeSuggestion('dw1', {
|
|
4527
|
+
isDomainWide: true,
|
|
4528
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4529
|
+
});
|
|
4530
|
+
|
|
4531
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4532
|
+
uploadMetaconfigStub.rejects(new Error('upload failed'));
|
|
4533
|
+
|
|
4534
|
+
const result = await client.deployToEdge({
|
|
4535
|
+
site: mockSite,
|
|
4536
|
+
opportunity: mockOpportunity,
|
|
4537
|
+
targetSuggestions: [dw],
|
|
4538
|
+
allSuggestions: [dw],
|
|
4539
|
+
});
|
|
4540
|
+
|
|
4541
|
+
expect(result.succeededSuggestions).to.not.include(dw);
|
|
4542
|
+
expect(result.failedSuggestions).to.have.length(1);
|
|
4543
|
+
expect(result.failedSuggestions[0].statusCode).to.equal(500);
|
|
4544
|
+
expect(result.failedSuggestions[0].reason).to.equal('upload failed');
|
|
4545
|
+
});
|
|
4546
|
+
|
|
4547
|
+
it('should skip invalid regex in same-batch pattern filtering without throwing', async () => {
|
|
4548
|
+
const dw = makeSuggestion('dw1', {
|
|
4549
|
+
isDomainWide: true,
|
|
4550
|
+
allowedRegexPatterns: ['[invalid', '^https://example\\.com/.*'],
|
|
4551
|
+
});
|
|
4552
|
+
const regular = makeSuggestion('r1', { url: 'https://example.com/page1' });
|
|
4553
|
+
|
|
4554
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4555
|
+
|
|
4556
|
+
const result = await client.deployToEdge({
|
|
4557
|
+
site: mockSite,
|
|
4558
|
+
opportunity: mockOpportunity,
|
|
4559
|
+
targetSuggestions: [dw, regular],
|
|
4560
|
+
allSuggestions: [dw, regular],
|
|
4561
|
+
});
|
|
4562
|
+
|
|
4563
|
+
// valid pattern matches regular, so it's skipped in batch
|
|
4564
|
+
expect(result.coveredSuggestions).to.include(regular);
|
|
4565
|
+
});
|
|
4566
|
+
|
|
4567
|
+
it('should skip domain-wide suggestion with no valid allowedRegexPatterns', async () => {
|
|
4568
|
+
const dw = makeSuggestion('dw1', {
|
|
4569
|
+
isDomainWide: true,
|
|
4570
|
+
allowedRegexPatterns: [],
|
|
4571
|
+
});
|
|
4572
|
+
|
|
4573
|
+
const result = await client.deployToEdge({
|
|
4574
|
+
site: mockSite,
|
|
4575
|
+
opportunity: mockOpportunity,
|
|
4576
|
+
targetSuggestions: [dw],
|
|
4577
|
+
allSuggestions: [dw],
|
|
4578
|
+
});
|
|
4579
|
+
|
|
4580
|
+
expect(result.succeededSuggestions).to.have.length(0);
|
|
4581
|
+
expect(result.failedSuggestions).to.have.length(0);
|
|
4582
|
+
expect(uploadMetaconfigStub).to.not.have.been.called;
|
|
4583
|
+
});
|
|
4584
|
+
|
|
4585
|
+
it('should warn and skip invalid regex patterns for domain-wide', async () => {
|
|
4586
|
+
const dw = makeSuggestion('dw1', {
|
|
4587
|
+
isDomainWide: true,
|
|
4588
|
+
allowedRegexPatterns: ['[invalid', '^https://example\\.com/.*'],
|
|
4589
|
+
});
|
|
4590
|
+
|
|
4591
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4592
|
+
|
|
4593
|
+
await client.deployToEdge({
|
|
4594
|
+
site: mockSite,
|
|
4595
|
+
opportunity: mockOpportunity,
|
|
4596
|
+
targetSuggestions: [dw],
|
|
4597
|
+
allSuggestions: [dw],
|
|
4598
|
+
});
|
|
4599
|
+
|
|
4600
|
+
expect(log.warn).to.have.been.called;
|
|
4601
|
+
expect(uploadMetaconfigStub).to.have.been.calledOnce;
|
|
4602
|
+
});
|
|
4603
|
+
|
|
4604
|
+
it('should skip same-batch regular suggestions covered by domain-wide patterns', async () => {
|
|
4605
|
+
const dw = makeSuggestion('dw1', {
|
|
4606
|
+
isDomainWide: true,
|
|
4607
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4608
|
+
});
|
|
4609
|
+
const regular = makeSuggestion('r1', { url: 'https://example.com/page1' });
|
|
4610
|
+
|
|
4611
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4612
|
+
|
|
4613
|
+
const result = await client.deployToEdge({
|
|
4614
|
+
site: mockSite,
|
|
4615
|
+
opportunity: mockOpportunity,
|
|
4616
|
+
targetSuggestions: [dw, regular],
|
|
4617
|
+
allSuggestions: [dw, regular],
|
|
4618
|
+
});
|
|
4619
|
+
|
|
4620
|
+
// regular was in same batch as domain-wide, should be marked covered
|
|
4621
|
+
expect(result.coveredSuggestions).to.include(regular);
|
|
4622
|
+
expect(regular.getData()).to.have.property('coveredByDomainWide', 'same-batch-deployment');
|
|
4623
|
+
expect(regular.getData()).to.have.property('skippedInDeployment', true);
|
|
4624
|
+
// deploySuggestions should NOT have been called for the regular suggestion
|
|
4625
|
+
expect(deploySuggestionsStub).to.not.have.been.called;
|
|
4626
|
+
});
|
|
4627
|
+
|
|
4628
|
+
it('should surface same-batch save failures as failed suggestions with statusCode 500', async () => {
|
|
4629
|
+
const dw = makeSuggestion('dw1', {
|
|
4630
|
+
isDomainWide: true,
|
|
4631
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4632
|
+
});
|
|
4633
|
+
const regular = makeSuggestion('r1', { url: 'https://example.com/page1' });
|
|
4634
|
+
regular.save = sinon.stub().rejects(new Error('save failed'));
|
|
4635
|
+
|
|
4636
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4637
|
+
|
|
4638
|
+
const result = await client.deployToEdge({
|
|
4639
|
+
site: mockSite,
|
|
4640
|
+
opportunity: mockOpportunity,
|
|
4641
|
+
targetSuggestions: [dw, regular],
|
|
4642
|
+
allSuggestions: [dw, regular],
|
|
4643
|
+
});
|
|
4644
|
+
|
|
4645
|
+
expect(result.failedSuggestions.some((f) => f.suggestion === regular)).to.be.true;
|
|
4646
|
+
expect(result.failedSuggestions.find((f) => f.suggestion === regular).statusCode)
|
|
4647
|
+
.to.equal(500);
|
|
4648
|
+
expect(result.coveredSuggestions).to.not.include(regular);
|
|
4649
|
+
expect(log.warn).to.have.been.called;
|
|
4650
|
+
});
|
|
4651
|
+
|
|
4652
|
+
it('should use default updatedBy when not provided', async () => {
|
|
4653
|
+
const s1 = makeSuggestion('s1', { url: 'https://example.com/page1', transformRules: {} });
|
|
4654
|
+
|
|
4655
|
+
deploySuggestionsStub.resolves({
|
|
4656
|
+
succeededSuggestions: [s1],
|
|
4657
|
+
failedSuggestions: [],
|
|
4658
|
+
});
|
|
4659
|
+
|
|
4660
|
+
await client.deployToEdge({
|
|
4661
|
+
site: mockSite,
|
|
4662
|
+
opportunity: mockOpportunity,
|
|
4663
|
+
targetSuggestions: [s1],
|
|
4664
|
+
allSuggestions: [s1],
|
|
4665
|
+
});
|
|
4666
|
+
|
|
4667
|
+
expect(s1.getUpdatedBy()).to.equal('edge-deploy');
|
|
4668
|
+
});
|
|
4669
|
+
|
|
4670
|
+
it('should deploy regular suggestion alongside domain-wide when URL does not match pattern', async () => {
|
|
4671
|
+
const dw = makeSuggestion('dw1', {
|
|
4672
|
+
isDomainWide: true,
|
|
4673
|
+
allowedRegexPatterns: ['^https://example\\.com/special/.*'],
|
|
4674
|
+
});
|
|
4675
|
+
// URL does not match the domain-wide pattern, so stays in validSuggestions
|
|
4676
|
+
const regular = makeSuggestion('r1', { url: 'https://example.com/other/page' });
|
|
4677
|
+
|
|
4678
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4679
|
+
deploySuggestionsStub.resolves({
|
|
4680
|
+
succeededSuggestions: [regular],
|
|
4681
|
+
failedSuggestions: [],
|
|
4682
|
+
});
|
|
4683
|
+
|
|
4684
|
+
const result = await client.deployToEdge({
|
|
4685
|
+
site: mockSite,
|
|
4686
|
+
opportunity: mockOpportunity,
|
|
4687
|
+
targetSuggestions: [dw, regular],
|
|
4688
|
+
allSuggestions: [dw, regular],
|
|
4689
|
+
});
|
|
4690
|
+
|
|
4691
|
+
expect(deploySuggestionsStub).to.have.been.calledOnce;
|
|
4692
|
+
expect(result.succeededSuggestions).to.include(regular);
|
|
4693
|
+
expect(result.coveredSuggestions).to.not.include(regular);
|
|
4694
|
+
});
|
|
4695
|
+
|
|
4696
|
+
it('should not call deploySuggestions when all regular suggestions are skipped in batch', async () => {
|
|
4697
|
+
const dw = makeSuggestion('dw1', {
|
|
4698
|
+
isDomainWide: true,
|
|
4699
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4700
|
+
});
|
|
4701
|
+
const r1 = makeSuggestion('r1', { url: 'https://example.com/page1' });
|
|
4702
|
+
const r2 = makeSuggestion('r2', { url: 'https://example.com/page2' });
|
|
4703
|
+
|
|
4704
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4705
|
+
|
|
4706
|
+
await client.deployToEdge({
|
|
4707
|
+
site: mockSite,
|
|
4708
|
+
opportunity: mockOpportunity,
|
|
4709
|
+
targetSuggestions: [dw, r1, r2],
|
|
4710
|
+
allSuggestions: [dw, r1, r2],
|
|
4711
|
+
});
|
|
4712
|
+
|
|
4713
|
+
expect(deploySuggestionsStub).to.not.have.been.called;
|
|
4714
|
+
});
|
|
4715
|
+
|
|
4716
|
+
it('should not include same-batch skipped suggestions in covered-by-pattern check', async () => {
|
|
4717
|
+
const dw = makeSuggestion('dw1', {
|
|
4718
|
+
isDomainWide: true,
|
|
4719
|
+
allowedRegexPatterns: ['^https://example\\.com/.*'],
|
|
4720
|
+
});
|
|
4721
|
+
const skipped = makeSuggestion('r1', { url: 'https://example.com/page1' });
|
|
4722
|
+
|
|
4723
|
+
fetchMetaconfigStub.resolves({ siteId: 'site-123' });
|
|
4724
|
+
|
|
4725
|
+
const result = await client.deployToEdge({
|
|
4726
|
+
site: mockSite,
|
|
4727
|
+
opportunity: mockOpportunity,
|
|
4728
|
+
targetSuggestions: [dw, skipped],
|
|
4729
|
+
allSuggestions: [dw, skipped],
|
|
4730
|
+
});
|
|
4731
|
+
|
|
4732
|
+
// skipped is in same batch, it should be in coveredSuggestions once (via same-batch path)
|
|
4733
|
+
// but NOT doubly added via the per-suggestion covered-marking path
|
|
4734
|
+
const skippedInCovered = result.coveredSuggestions.filter((s) => s.getId() === 'r1');
|
|
4735
|
+
expect(skippedInCovered).to.have.length(1);
|
|
4736
|
+
});
|
|
4737
|
+
});
|
|
4256
4738
|
});
|