@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 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.12.3",
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.1019.0",
39
- "@aws-sdk/client-s3": "3.1019.0",
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
@@ -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
  });