@adobe/spacecat-shared-tokowaka-client 1.17.3 → 1.18.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.
@@ -48,12 +48,17 @@ describe('TokowakaClient', () => {
48
48
  }),
49
49
  };
50
50
 
51
+ const dataAccess = {
52
+ Suggestion: { saveMany: sinon.stub().resolves() },
53
+ };
54
+
51
55
  client = new TokowakaClient(
52
56
  {
53
57
  bucketName: 'test-bucket',
54
58
  previewBucketName: 'test-preview-bucket',
55
59
  s3Client,
56
60
  env,
61
+ dataAccess,
57
62
  },
58
63
  log,
59
64
  );
@@ -87,6 +92,9 @@ describe('TokowakaClient', () => {
87
92
  selector: 'h1',
88
93
  },
89
94
  }),
95
+ setData: sinon.stub(),
96
+ setUpdatedBy: sinon.stub(),
97
+ save: sinon.stub().resolves(),
90
98
  },
91
99
  {
92
100
  getId: () => 'sugg-2',
@@ -100,6 +108,9 @@ describe('TokowakaClient', () => {
100
108
  selector: 'h2',
101
109
  },
102
110
  }),
111
+ setData: sinon.stub(),
112
+ setUpdatedBy: sinon.stub(),
113
+ save: sinon.stub().resolves(),
103
114
  },
104
115
  ];
105
116
  });
@@ -2568,6 +2579,9 @@ describe('TokowakaClient', () => {
2568
2579
  getData: () => ({
2569
2580
  url: 'https://example.com/page1',
2570
2581
  }),
2582
+ setData: sinon.stub(),
2583
+ setUpdatedBy: sinon.stub(),
2584
+ save: sinon.stub().resolves(),
2571
2585
  },
2572
2586
  ];
2573
2587
 
@@ -2798,6 +2812,9 @@ describe('TokowakaClient', () => {
2798
2812
  selector: 'h1',
2799
2813
  },
2800
2814
  }),
2815
+ setData: sinon.stub(),
2816
+ setUpdatedBy: sinon.stub(),
2817
+ save: sinon.stub().resolves(),
2801
2818
  },
2802
2819
  {
2803
2820
  getId: () => 'sugg-2',
@@ -2811,6 +2828,9 @@ describe('TokowakaClient', () => {
2811
2828
  selector: 'h1',
2812
2829
  },
2813
2830
  }),
2831
+ setData: sinon.stub(),
2832
+ setUpdatedBy: sinon.stub(),
2833
+ save: sinon.stub().resolves(),
2814
2834
  },
2815
2835
  ];
2816
2836
 
@@ -2900,6 +2920,9 @@ describe('TokowakaClient', () => {
2900
2920
  selector: 'body',
2901
2921
  },
2902
2922
  }),
2923
+ setData: sinon.stub(),
2924
+ setUpdatedBy: sinon.stub(),
2925
+ save: sinon.stub().resolves(),
2903
2926
  };
2904
2927
 
2905
2928
  const existingConfig = {
@@ -2944,242 +2967,1231 @@ describe('TokowakaClient', () => {
2944
2967
  const command = s3Client.send.firstCall.args[0];
2945
2968
  expect(command.constructor.name).to.equal('PutObjectCommand');
2946
2969
  });
2947
- });
2948
-
2949
- describe('previewSuggestions', () => {
2950
- let fetchStub;
2951
-
2952
- beforeEach(() => {
2953
- // Stub global fetch for HTML fetching
2954
- fetchStub = sinon.stub(global, 'fetch');
2955
- // Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
2956
- fetchStub.resolves({
2957
- ok: true,
2958
- status: 200,
2959
- statusText: 'OK',
2960
- headers: {
2961
- get: (name) => (name === 'x-edgeoptimize-cache' ? 'HIT' : null),
2962
- },
2963
- text: async () => '<html><body>Test HTML</body></html>',
2964
- });
2965
-
2966
- // Stub CDN invalidation for preview tests
2967
- sinon.stub(client, 'invalidateCdnCache').resolves({
2968
- status: 'success',
2969
- provider: 'cloudfront',
2970
- invalidationId: 'I123',
2971
- });
2972
2970
 
2973
- // Stub fetchConfig to return null by default (no existing config)
2974
- sinon.stub(client, 'fetchConfig').resolves(null);
2971
+ it('path rollback removes only the matching pattern and leaves /* intact', async () => {
2972
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
2973
+ const pathSuggestion = {
2974
+ getId: () => 'path-1',
2975
+ getData: () => ({
2976
+ allowedRegexPatterns: ['/products/*'],
2977
+ edgeDeployed: Date.now(),
2978
+ }),
2979
+ setData: sinon.stub(),
2980
+ setUpdatedBy: sinon.stub(),
2981
+ save: sinon.stub().resolves(),
2982
+ };
2975
2983
 
2976
- // Stub fetchMetaconfig to return metaconfig with apiKeys array
2977
2984
  sinon.stub(client, 'fetchMetaconfig').resolves({
2978
2985
  siteId: 'site-123',
2979
- apiKeys: ['test-api-key-1', 'test-api-key-2'],
2986
+ prerender: { allowList: ['/*', '/products/*'] },
2980
2987
  });
2988
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
2981
2989
 
2982
- // Add TOKOWAKA_EDGE_URL to env
2983
- client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
2984
- });
2985
-
2986
- afterEach(() => {
2987
- // fetchStub will be restored by global afterEach sinon.restore()
2988
- // Just clean up env changes
2989
- delete client.env.TOKOWAKA_EDGE_URL;
2990
- });
2991
-
2992
- it('should preview suggestions successfully with HTML', async () => {
2993
- const result = await client.previewSuggestions(
2990
+ const result = await client.rollbackSuggestions(
2994
2991
  mockSite,
2995
- mockOpportunity,
2996
- mockSuggestions,
2997
- { warmupDelayMs: 0 },
2992
+ prerenderOpportunity,
2993
+ [pathSuggestion],
2998
2994
  );
2999
2995
 
3000
- expect(result).to.have.property('s3Path', 'preview/opportunities/example.com/L3BhZ2Ux');
3001
- expect(result).to.have.property('succeededSuggestions');
3002
- expect(result.succeededSuggestions).to.have.length(2);
3003
- expect(result).to.have.property('failedSuggestions');
2996
+ expect(result.succeededSuggestions).to.include(pathSuggestion);
3004
2997
  expect(result.failedSuggestions).to.have.length(0);
3005
- expect(result).to.have.property('html');
3006
- expect(result.html).to.have.property('url', 'https://example.com/page1');
3007
- expect(result.html).to.have.property('originalHtml');
3008
- expect(result.html).to.have.property('optimizedHtml');
3009
- expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
3010
- expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
3011
-
3012
- // Verify fetch was called for HTML fetching
3013
- // (4 times: warmup + actual for original and optimized)
3014
- expect(fetchStub.callCount).to.equal(4);
3015
- expect(s3Client.send).to.have.been.calledOnce;
2998
+ const uploadedConfig = uploadStub.firstCall.args[1];
2999
+ expect(uploadedConfig.prerender.allowList).to.deep.equal(['/*']);
3016
3000
  });
3017
3001
 
3018
- it('should set applyStale to true for all preview patches', async () => {
3019
- // Create a scenario with existing deployed patches
3020
- client.fetchConfig.resolves({
3021
- url: 'https://example.com/page1',
3022
- version: '1.0',
3023
- forceFail: false,
3024
- prerender: true,
3025
- patches: [
3026
- {
3027
- op: 'replace',
3028
- selector: 'h3',
3029
- value: 'Existing Heading',
3030
- opportunityId: 'opp-999',
3031
- suggestionId: 'sugg-999',
3032
- prerenderRequired: true,
3033
- lastUpdated: 1234567890,
3034
- },
3035
- ],
3002
+ it('path rollback of last remaining pattern deletes prerender key entirely', async () => {
3003
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3004
+ const pathSuggestion = {
3005
+ getId: () => 'path-1',
3006
+ getData: () => ({
3007
+ allowedRegexPatterns: ['/products/*'],
3008
+ edgeDeployed: Date.now(),
3009
+ }),
3010
+ setData: sinon.stub(),
3011
+ setUpdatedBy: sinon.stub(),
3012
+ save: sinon.stub().resolves(),
3013
+ };
3014
+
3015
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3016
+ siteId: 'site-123',
3017
+ prerender: { allowList: ['/products/*'] },
3036
3018
  });
3019
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3037
3020
 
3038
- const result = await client.previewSuggestions(
3021
+ const result = await client.rollbackSuggestions(
3039
3022
  mockSite,
3040
- mockOpportunity,
3041
- mockSuggestions,
3042
- { warmupDelayMs: 0 },
3043
- );
3044
-
3045
- expect(result.config).to.exist;
3046
- expect(result.config.patches).to.have.length(3); // 2 new + 1 existing
3047
-
3048
- // Verify that all new preview patches have applyStale: true
3049
- const newPatches = result.config.patches.filter(
3050
- (p) => p.suggestionId === 'sugg-1' || p.suggestionId === 'sugg-2',
3023
+ prerenderOpportunity,
3024
+ [pathSuggestion],
3051
3025
  );
3052
- expect(newPatches).to.have.length(2);
3053
- newPatches.forEach((patch) => {
3054
- expect(patch.applyStale).to.equal(true);
3055
- });
3056
3026
 
3057
- // Existing patch should not have applyStale field
3058
- const existingPatch = result.config.patches.find((p) => p.suggestionId === 'sugg-999');
3059
- expect(existingPatch).to.exist;
3060
- expect(existingPatch.applyStale).to.be.undefined;
3027
+ expect(result.succeededSuggestions).to.include(pathSuggestion);
3028
+ const uploadedConfig = uploadStub.firstCall.args[1];
3029
+ expect(uploadedConfig).to.not.have.property('prerender');
3061
3030
  });
3062
3031
 
3063
- it('should preview prerender-only suggestions with no patches', async () => {
3064
- // Update fetchConfig to return existing config with deployed patches
3065
- client.fetchConfig.resolves({
3066
- url: 'https://example.com/page1',
3067
- version: '1.0',
3068
- forceFail: false,
3069
- prerender: false,
3070
- patches: [
3071
- {
3072
- op: 'replace',
3073
- selector: 'h1',
3074
- value: 'Existing Heading',
3075
- opportunityId: 'opp-other-123',
3076
- suggestionId: 'sugg-other',
3077
- prerenderRequired: false,
3078
- lastUpdated: 1234567890,
3079
- },
3080
- ],
3081
- });
3082
-
3083
- // Create prerender opportunity
3084
- const prerenderOpportunity = {
3085
- getId: () => 'opp-prerender-123',
3086
- getType: () => 'prerender',
3032
+ it('domain-wide rollback removes only /* from metaconfig allowList', async () => {
3033
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3034
+ const dwSuggestion = {
3035
+ getId: () => 'dw-1',
3036
+ getData: () => ({
3037
+ isDomainWide: true,
3038
+ allowedRegexPatterns: ['/*'],
3039
+ edgeDeployed: Date.now(),
3040
+ }),
3041
+ setData: sinon.stub(),
3042
+ setUpdatedBy: sinon.stub(),
3043
+ save: sinon.stub().resolves(),
3087
3044
  };
3088
3045
 
3089
- // Create prerender suggestions with no transform rules (prerender-only)
3090
- const prerenderSuggestions = [
3091
- {
3092
- getId: () => 'prerender-sugg-1',
3093
- getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
3094
- getData: () => ({
3095
- url: 'https://example.com/page1',
3096
- // No transform rules - prerender only
3097
- }),
3098
- },
3099
- ];
3046
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3047
+ siteId: 'site-123',
3048
+ prerender: { allowList: ['/*', '/products/*'] },
3049
+ });
3050
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3100
3051
 
3101
- const result = await client.previewSuggestions(
3052
+ const result = await client.rollbackSuggestions(
3102
3053
  mockSite,
3103
3054
  prerenderOpportunity,
3104
- prerenderSuggestions,
3105
- { warmupDelayMs: 0 },
3055
+ [dwSuggestion],
3106
3056
  );
3107
3057
 
3108
- expect(result).to.have.property('s3Path');
3109
- expect(result.config).to.not.be.null;
3110
- expect(result.config.patches).to.have.length(1); // Merged with existing deployed patch
3111
- expect(result.config.prerender).to.equal(true); // Prerender enabled
3112
- expect(result.succeededSuggestions).to.have.length(1);
3113
- expect(result.failedSuggestions).to.have.length(0);
3114
- expect(result).to.have.property('html');
3115
-
3116
- // Verify fetch was called for HTML fetching
3117
- expect(fetchStub.callCount).to.equal(4);
3118
- expect(s3Client.send).to.have.been.calledOnce;
3058
+ expect(result.succeededSuggestions).to.include(dwSuggestion);
3059
+ const uploadedConfig = uploadStub.firstCall.args[1];
3060
+ expect(uploadedConfig.prerender.allowList).to.deep.equal(['/products/*']);
3119
3061
  });
3120
3062
 
3121
- it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
3122
- delete client.env.TOKOWAKA_EDGE_URL;
3123
-
3124
- try {
3125
- await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
3126
- expect.fail('Should have thrown error');
3127
- } catch (error) {
3128
- expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
3129
- expect(error.status).to.equal(500);
3130
- }
3131
- });
3063
+ it('cleans up covered suggestions (coveredByDomainWide) when rolling back a pattern suggestion', async () => {
3064
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3065
+ const dwSuggestion = {
3066
+ getId: () => 'dw-1',
3067
+ getData: () => ({
3068
+ isDomainWide: true,
3069
+ allowedRegexPatterns: ['/*'],
3070
+ edgeDeployed: Date.now(),
3071
+ tokowakaDeployed: Date.now(),
3072
+ }),
3073
+ setData: sinon.stub(),
3074
+ setUpdatedBy: sinon.stub(),
3075
+ save: sinon.stub().resolves(),
3076
+ };
3077
+ const coveredSuggestion = {
3078
+ getId: () => 'covered-1',
3079
+ getData: () => ({
3080
+ url: 'https://example.com/page1',
3081
+ edgeDeployed: Date.now(),
3082
+ coveredByDomainWide: 'dw-1',
3083
+ }),
3084
+ setData: sinon.stub(),
3085
+ setUpdatedBy: sinon.stub(),
3086
+ save: sinon.stub().resolves(),
3087
+ };
3132
3088
 
3133
- it('should preview suggestions successfully without metaconfig (optional)', async () => {
3134
- // Override the stub from beforeEach to return null
3135
- client.fetchMetaconfig.resolves(null);
3089
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3090
+ siteId: 'site-123',
3091
+ prerender: { allowList: ['/*'] },
3092
+ });
3093
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3136
3094
 
3137
- const result = await client.previewSuggestions(
3095
+ const result = await client.rollbackSuggestions(
3138
3096
  mockSite,
3139
- mockOpportunity,
3140
- mockSuggestions,
3141
- { warmupDelayMs: 0 },
3097
+ prerenderOpportunity,
3098
+ [dwSuggestion],
3099
+ { allSuggestions: [dwSuggestion, coveredSuggestion], updatedBy: 'test@example.com' },
3142
3100
  );
3143
3101
 
3144
- expect(result.succeededSuggestions).to.have.length(2);
3145
- expect(result.failedSuggestions).to.have.length(0);
3146
- expect(result.config).to.exist;
3147
- expect(result.html).to.exist;
3148
- expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
3149
- expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
3102
+ expect(result.succeededSuggestions).to.include(dwSuggestion);
3150
3103
 
3151
- // Verify fetch was called without API key (undefined)
3152
- expect(fetchStub.callCount).to.equal(4); // 2 warmup + 2 actual (original + optimized)
3104
+ // Verify domain-wide suggestion was updated and batch-saved
3105
+ expect(dwSuggestion.setUpdatedBy.calledWith('test@example.com')).to.be.true;
3106
+ const dwData = dwSuggestion.setData.firstCall.args[0];
3107
+ expect(dwData).to.not.have.property('edgeDeployed');
3108
+ expect(dwData).to.not.have.property('tokowakaDeployed');
3109
+
3110
+ // Verify covered suggestion was also cleaned up.
3111
+ // DW rollback only strips coveredByDomainWide — edgeDeployed is
3112
+ // preserved because it represents an independent per-URL deployment.
3113
+ expect(coveredSuggestion.setData.calledOnce).to.be.true;
3114
+ expect(coveredSuggestion.setUpdatedBy.calledWith('test@example.com')).to.be.true;
3115
+ const coveredData = coveredSuggestion.setData.firstCall.args[0];
3116
+ expect(coveredData).to.have.property('edgeDeployed');
3117
+ expect(coveredData).to.not.have.property('coveredByDomainWide');
3153
3118
  });
3154
3119
 
3155
- it('should preview suggestions successfully without apiKeys in metaconfig (optional)', async () => {
3156
- // Override the stub from beforeEach
3157
- client.fetchMetaconfig.resolves({
3120
+ it('cleans up covered suggestions (coveredByPattern) when rolling back a path-level pattern', async () => {
3121
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3122
+ const pathSuggestion = {
3123
+ getId: () => 'path-1',
3124
+ getData: () => ({
3125
+ allowedRegexPatterns: ['/products/*'],
3126
+ edgeDeployed: Date.now(),
3127
+ }),
3128
+ setData: sinon.stub(),
3129
+ setUpdatedBy: sinon.stub(),
3130
+ save: sinon.stub().resolves(),
3131
+ };
3132
+ const coveredSuggestion = {
3133
+ getId: () => 'covered-2',
3134
+ getData: () => ({
3135
+ url: 'https://example.com/products/item',
3136
+ edgeDeployed: Date.now(),
3137
+ coveredByPattern: 'path-1',
3138
+ }),
3139
+ setData: sinon.stub(),
3140
+ setUpdatedBy: sinon.stub(),
3141
+ save: sinon.stub().resolves(),
3142
+ };
3143
+
3144
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3158
3145
  siteId: 'site-123',
3159
- // apiKeys missing - should work without it
3146
+ prerender: { allowList: ['/products/*'] },
3160
3147
  });
3148
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3161
3149
 
3162
- const result = await client.previewSuggestions(
3150
+ await client.rollbackSuggestions(
3163
3151
  mockSite,
3164
- mockOpportunity,
3165
- mockSuggestions,
3166
- { warmupDelayMs: 0 },
3152
+ prerenderOpportunity,
3153
+ [pathSuggestion],
3154
+ { allSuggestions: [pathSuggestion, coveredSuggestion] },
3167
3155
  );
3168
3156
 
3169
- expect(result.succeededSuggestions).to.have.length(2);
3170
- expect(result.failedSuggestions).to.have.length(0);
3171
- expect(result.config).to.exist;
3172
- expect(result.html).to.exist;
3173
- expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
3174
- expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
3175
- expect(fetchStub.callCount).to.equal(4);
3157
+ expect(coveredSuggestion.setData.calledOnce).to.be.true;
3158
+ const coveredData = coveredSuggestion.setData.firstCall.args[0];
3159
+ expect(coveredData).to.not.have.property('edgeDeployed');
3160
+ expect(coveredData).to.not.have.property('coveredByPattern');
3176
3161
  });
3177
3162
 
3178
- it('should preview suggestions successfully with empty apiKeys array (optional)', async () => {
3179
- // Override the stub from beforeEach
3180
- client.fetchMetaconfig.resolves({
3181
- siteId: 'site-123',
3182
- apiKeys: [],
3163
+ it('uses domain-wide-rollback fallback for covered suggestions when updatedBy is not provided (domain-wide parent)', async () => {
3164
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3165
+ const dwSuggestion = {
3166
+ getId: () => 'dw-1',
3167
+ getData: () => ({
3168
+ isDomainWide: true,
3169
+ allowedRegexPatterns: ['/*'],
3170
+ edgeDeployed: Date.now(),
3171
+ }),
3172
+ setData: sinon.stub(),
3173
+ setUpdatedBy: sinon.stub(),
3174
+ save: sinon.stub().resolves(),
3175
+ };
3176
+ const coveredSuggestion = {
3177
+ getId: () => 'covered-1',
3178
+ getData: () => ({
3179
+ url: 'https://example.com/page1',
3180
+ edgeDeployed: Date.now(),
3181
+ coveredByDomainWide: 'dw-1',
3182
+ }),
3183
+ setData: sinon.stub(),
3184
+ setUpdatedBy: sinon.stub(),
3185
+ save: sinon.stub().resolves(),
3186
+ };
3187
+
3188
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3189
+ prerender: { allowList: ['/*'] },
3190
+ });
3191
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3192
+
3193
+ // No updatedBy passed — shared client should use context-specific fallbacks
3194
+ await client.rollbackSuggestions(
3195
+ mockSite,
3196
+ prerenderOpportunity,
3197
+ [dwSuggestion],
3198
+ { allSuggestions: [dwSuggestion, coveredSuggestion] },
3199
+ );
3200
+
3201
+ // The domain-wide suggestion itself uses 'tokowaka-rollback'
3202
+ expect(dwSuggestion.setUpdatedBy.calledWith('tokowaka-rollback')).to.be.true;
3203
+ // The covered suggestion uses 'domain-wide-rollback'
3204
+ expect(coveredSuggestion.setUpdatedBy.calledWith('domain-wide-rollback')).to.be.true;
3205
+ });
3206
+
3207
+ it('uses path-rollback fallback for covered suggestions when updatedBy is not provided (path parent)', async () => {
3208
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3209
+ const pathSuggestion = {
3210
+ getId: () => 'path-1',
3211
+ getData: () => ({
3212
+ allowedRegexPatterns: ['/products/*'],
3213
+ edgeDeployed: Date.now(),
3214
+ }),
3215
+ setData: sinon.stub(),
3216
+ setUpdatedBy: sinon.stub(),
3217
+ save: sinon.stub().resolves(),
3218
+ };
3219
+ const coveredSuggestion = {
3220
+ getId: () => 'covered-2',
3221
+ getData: () => ({
3222
+ url: 'https://example.com/products/item',
3223
+ edgeDeployed: Date.now(),
3224
+ coveredByPattern: 'path-1',
3225
+ }),
3226
+ setData: sinon.stub(),
3227
+ setUpdatedBy: sinon.stub(),
3228
+ save: sinon.stub().resolves(),
3229
+ };
3230
+
3231
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3232
+ prerender: { allowList: ['/products/*'] },
3233
+ });
3234
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3235
+
3236
+ await client.rollbackSuggestions(
3237
+ mockSite,
3238
+ prerenderOpportunity,
3239
+ [pathSuggestion],
3240
+ { allSuggestions: [pathSuggestion, coveredSuggestion] },
3241
+ );
3242
+
3243
+ // The path suggestion itself uses 'tokowaka-rollback'
3244
+ expect(pathSuggestion.setUpdatedBy.calledWith('tokowaka-rollback')).to.be.true;
3245
+ // The covered suggestion uses 'path-rollback'
3246
+ expect(coveredSuggestion.setUpdatedBy.calledWith('path-rollback')).to.be.true;
3247
+ });
3248
+
3249
+ it('DW rollback does NOT cascade to deployed path suggestions (paths are independent)', async () => {
3250
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3251
+ const dwSuggestion = {
3252
+ getId: () => 'dw-1',
3253
+ getData: () => ({
3254
+ isDomainWide: true,
3255
+ allowedRegexPatterns: ['/*'],
3256
+ edgeDeployed: Date.now(),
3257
+ }),
3258
+ setData: sinon.stub(),
3259
+ setUpdatedBy: sinon.stub(),
3260
+ save: sinon.stub().resolves(),
3261
+ };
3262
+ const deployedPathSuggestion = {
3263
+ getId: () => 'path-1',
3264
+ getData: () => ({
3265
+ allowedRegexPatterns: ['/products/*'],
3266
+ edgeDeployed: Date.now(),
3267
+ }),
3268
+ setData: sinon.stub(),
3269
+ setUpdatedBy: sinon.stub(),
3270
+ save: sinon.stub().resolves(),
3271
+ };
3272
+ const pathCoveredSuggestion = {
3273
+ getId: () => 'covered-path-1',
3274
+ getData: () => ({
3275
+ url: 'https://example.com/products/item',
3276
+ edgeDeployed: Date.now(),
3277
+ coveredByPattern: 'path-1',
3278
+ }),
3279
+ setData: sinon.stub(),
3280
+ setUpdatedBy: sinon.stub(),
3281
+ save: sinon.stub().resolves(),
3282
+ };
3283
+
3284
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3285
+ prerender: { allowList: ['/*', '/products/*'] },
3286
+ });
3287
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3288
+
3289
+ const result = await client.rollbackSuggestions(
3290
+ mockSite,
3291
+ prerenderOpportunity,
3292
+ [dwSuggestion],
3293
+ { allSuggestions: [dwSuggestion, deployedPathSuggestion, pathCoveredSuggestion] },
3294
+ );
3295
+
3296
+ // Domain-wide suggestion was rolled back
3297
+ expect(result.succeededSuggestions).to.include(dwSuggestion);
3298
+
3299
+ // Path suggestion was NOT cascaded — it is independent
3300
+ expect(deployedPathSuggestion.save.called).to.be.false;
3301
+ expect(deployedPathSuggestion.setData.called).to.be.false;
3302
+
3303
+ // Covered per-URL suggestion under the path was NOT touched
3304
+ expect(pathCoveredSuggestion.save.called).to.be.false;
3305
+ expect(pathCoveredSuggestion.setData.called).to.be.false;
3306
+ });
3307
+
3308
+ it('DW rollback preserves deployed path in metaconfig allowList (no cascade)', async () => {
3309
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3310
+ const dwSuggestion = {
3311
+ getId: () => 'dw-1',
3312
+ getData: () => ({
3313
+ isDomainWide: true,
3314
+ allowedRegexPatterns: ['/*'],
3315
+ edgeDeployed: Date.now(),
3316
+ }),
3317
+ setData: sinon.stub(),
3318
+ setUpdatedBy: sinon.stub(),
3319
+ save: sinon.stub().resolves(),
3320
+ };
3321
+ const deployedPathSuggestion = {
3322
+ getId: () => 'path-1',
3323
+ getData: () => ({
3324
+ allowedRegexPatterns: ['/products/*'],
3325
+ edgeDeployed: Date.now(),
3326
+ }),
3327
+ setData: sinon.stub(),
3328
+ setUpdatedBy: sinon.stub(),
3329
+ save: sinon.stub().resolves(),
3330
+ };
3331
+
3332
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3333
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3334
+ prerender: { allowList: ['/*', '/products/*', '/blog/*'] },
3335
+ });
3336
+
3337
+ await client.rollbackSuggestions(
3338
+ mockSite,
3339
+ prerenderOpportunity,
3340
+ [dwSuggestion],
3341
+ { allSuggestions: [dwSuggestion, deployedPathSuggestion] },
3342
+ );
3343
+
3344
+ // Only '/*' removed; '/products/*' and '/blog/*' preserved
3345
+ const { lastCall } = uploadStub;
3346
+ expect(lastCall.args[1].prerender.allowList).to.include('/products/*');
3347
+ expect(lastCall.args[1].prerender.allowList).to.include('/blog/*');
3348
+ expect(lastCall.args[1].prerender.allowList).to.not.include('/*');
3349
+
3350
+ // Path suggestion was NOT touched
3351
+ expect(deployedPathSuggestion.save.called).to.be.false;
3352
+ });
3353
+
3354
+ it('DW rollback with only DW in allowList — single upload, path untouched', async () => {
3355
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3356
+ const dwSuggestion = {
3357
+ getId: () => 'dw-1',
3358
+ getData: () => ({
3359
+ isDomainWide: true,
3360
+ allowedRegexPatterns: ['/*'],
3361
+ edgeDeployed: Date.now(),
3362
+ }),
3363
+ setData: sinon.stub(),
3364
+ setUpdatedBy: sinon.stub(),
3365
+ save: sinon.stub().resolves(),
3366
+ };
3367
+ const deployedPathSuggestion = {
3368
+ getId: () => 'path-1',
3369
+ getData: () => ({
3370
+ allowedRegexPatterns: ['/products/*'],
3371
+ edgeDeployed: Date.now(),
3372
+ }),
3373
+ setData: sinon.stub(),
3374
+ setUpdatedBy: sinon.stub(),
3375
+ save: sinon.stub().resolves(),
3376
+ };
3377
+
3378
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3379
+ prerender: { allowList: ['/*'] },
3380
+ });
3381
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3382
+
3383
+ await client.rollbackSuggestions(
3384
+ mockSite,
3385
+ prerenderOpportunity,
3386
+ [dwSuggestion],
3387
+ { allSuggestions: [dwSuggestion, deployedPathSuggestion] },
3388
+ );
3389
+
3390
+ // One upload for DW removal only
3391
+ expect(uploadStub.callCount).to.equal(1);
3392
+ // Path suggestion was NOT touched
3393
+ expect(deployedPathSuggestion.save.called).to.be.false;
3394
+ });
3395
+
3396
+ it('is a no-op cascade when no path suggestions are deployed', async () => {
3397
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3398
+ const dwSuggestion = {
3399
+ getId: () => 'dw-1',
3400
+ getData: () => ({
3401
+ isDomainWide: true,
3402
+ allowedRegexPatterns: ['/*'],
3403
+ edgeDeployed: Date.now(),
3404
+ }),
3405
+ setData: sinon.stub(),
3406
+ setUpdatedBy: sinon.stub(),
3407
+ save: sinon.stub().resolves(),
3408
+ };
3409
+ const undeployedPathSuggestion = {
3410
+ getId: () => 'path-1',
3411
+ getData: () => ({
3412
+ allowedRegexPatterns: ['/products/*'],
3413
+ // No edgeDeployed — should not be cascaded
3414
+ }),
3415
+ setData: sinon.stub(),
3416
+ setUpdatedBy: sinon.stub(),
3417
+ save: sinon.stub().resolves(),
3418
+ };
3419
+
3420
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3421
+ prerender: { allowList: ['/*'] },
3422
+ });
3423
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3424
+
3425
+ await client.rollbackSuggestions(
3426
+ mockSite,
3427
+ prerenderOpportunity,
3428
+ [dwSuggestion],
3429
+ { allSuggestions: [dwSuggestion, undeployedPathSuggestion] },
3430
+ );
3431
+
3432
+ // Undeployed path suggestion was not touched
3433
+ expect(undeployedPathSuggestion.save.called).to.be.false;
3434
+ expect(undeployedPathSuggestion.setUpdatedBy.called).to.be.false;
3435
+ });
3436
+
3437
+ it('path rollback marks suggestion ineligible when allowedRegexPatterns contains no valid pattern', async () => {
3438
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3439
+ const pathSuggestion = {
3440
+ getId: () => 'path-1',
3441
+ getData: () => ({ allowedRegexPatterns: [null] }),
3442
+ setData: sinon.stub(),
3443
+ setUpdatedBy: sinon.stub(),
3444
+ save: sinon.stub().resolves(),
3445
+ };
3446
+
3447
+ sinon.stub(client, 'fetchMetaconfig').resolves({ siteId: 'site-123' });
3448
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3449
+
3450
+ const result = await client.rollbackSuggestions(
3451
+ mockSite,
3452
+ prerenderOpportunity,
3453
+ [pathSuggestion],
3454
+ );
3455
+
3456
+ expect(result.succeededSuggestions).to.have.length(0);
3457
+ expect(result.failedSuggestions[0].suggestion).to.equal(pathSuggestion);
3458
+ expect(result.failedSuggestions[0].reason).to.equal('Missing allowedRegexPatterns');
3459
+ });
3460
+
3461
+ it('path rollback marks suggestion ineligible when no metaconfig exists', async () => {
3462
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3463
+ const pathSuggestion = {
3464
+ getId: () => 'path-1',
3465
+ getData: () => ({
3466
+ allowedRegexPatterns: ['/products/*'],
3467
+ }),
3468
+ setData: sinon.stub(),
3469
+ setUpdatedBy: sinon.stub(),
3470
+ save: sinon.stub().resolves(),
3471
+ };
3472
+
3473
+ sinon.stub(client, 'fetchMetaconfig').resolves(null);
3474
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3475
+
3476
+ const result = await client.rollbackSuggestions(
3477
+ mockSite,
3478
+ prerenderOpportunity,
3479
+ [pathSuggestion],
3480
+ );
3481
+
3482
+ expect(result.succeededSuggestions).to.have.length(0);
3483
+ expect(result.failedSuggestions[0].reason).to.equal('No metaconfig found');
3484
+ });
3485
+
3486
+ it('path rollback skips CDN write when metaconfig has no prerender key', async () => {
3487
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3488
+ const pathSuggestion = {
3489
+ getId: () => 'path-1',
3490
+ getData: () => ({
3491
+ allowedRegexPatterns: ['/products/*'],
3492
+ }),
3493
+ setData: sinon.stub(),
3494
+ setUpdatedBy: sinon.stub(),
3495
+ save: sinon.stub().resolves(),
3496
+ };
3497
+
3498
+ sinon.stub(client, 'fetchMetaconfig').resolves({ siteId: 'site-123' });
3499
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3500
+
3501
+ const result = await client.rollbackSuggestions(
3502
+ mockSite,
3503
+ prerenderOpportunity,
3504
+ [pathSuggestion],
3505
+ );
3506
+
3507
+ expect(result.succeededSuggestions).to.include(pathSuggestion);
3508
+ expect(uploadStub).to.not.have.been.called;
3509
+ });
3510
+
3511
+ it('path rollback skips CDN write when pattern is not in allowList', async () => {
3512
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3513
+ const pathSuggestion = {
3514
+ getId: () => 'path-1',
3515
+ getData: () => ({
3516
+ allowedRegexPatterns: ['/blog/*'],
3517
+ edgeDeployed: Date.now(),
3518
+ }),
3519
+ setData: sinon.stub(),
3520
+ setUpdatedBy: sinon.stub(),
3521
+ save: sinon.stub().resolves(),
3522
+ };
3523
+
3524
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3525
+ siteId: 'site-123',
3526
+ prerender: { allowList: ['/*'] },
3527
+ });
3528
+ const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
3529
+
3530
+ const result = await client.rollbackSuggestions(
3531
+ mockSite,
3532
+ prerenderOpportunity,
3533
+ [pathSuggestion],
3534
+ );
3535
+
3536
+ expect(result.succeededSuggestions).to.include(pathSuggestion);
3537
+ expect(uploadStub).to.not.have.been.called;
3538
+ });
3539
+
3540
+ it('path rollback marks suggestion as failed when metaconfig upload throws', async () => {
3541
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3542
+ const pathSuggestion = {
3543
+ getId: () => 'path-1',
3544
+ getData: () => ({
3545
+ allowedRegexPatterns: ['/products/*'],
3546
+ edgeDeployed: Date.now(),
3547
+ }),
3548
+ setData: sinon.stub(),
3549
+ setUpdatedBy: sinon.stub(),
3550
+ save: sinon.stub().resolves(),
3551
+ };
3552
+
3553
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3554
+ siteId: 'site-123',
3555
+ prerender: { allowList: ['/products/*'] },
3556
+ });
3557
+ sinon.stub(client, 'uploadMetaconfig').rejects(new Error('S3 failure'));
3558
+
3559
+ const result = await client.rollbackSuggestions(
3560
+ mockSite,
3561
+ prerenderOpportunity,
3562
+ [pathSuggestion],
3563
+ );
3564
+
3565
+ expect(result.succeededSuggestions).to.not.include(pathSuggestion);
3566
+ expect(result.failedSuggestions).to.have.length(1);
3567
+ expect(result.failedSuggestions[0].statusCode).to.equal(500);
3568
+ // Suggestion save should not have been called when metaconfig upload failed
3569
+ expect(pathSuggestion.save).to.not.have.been.called;
3570
+ });
3571
+
3572
+ it('rollback logs error when covered suggestion saveMany fails', async () => {
3573
+ const prerenderOpportunity = { getId: () => 'opp-dw', getType: () => 'prerender' };
3574
+ const dwSuggestion = {
3575
+ getId: () => 'dw-1',
3576
+ getData: () => ({
3577
+ isDomainWide: true,
3578
+ allowedRegexPatterns: ['/*'],
3579
+ edgeDeployed: Date.now(),
3580
+ }),
3581
+ setData: sinon.stub(),
3582
+ setUpdatedBy: sinon.stub(),
3583
+ save: sinon.stub().resolves(),
3584
+ };
3585
+ const covered = {
3586
+ getId: () => 'covered-1',
3587
+ getData: () => ({
3588
+ url: 'https://example.com/page1',
3589
+ coveredByDomainWide: 'dw-1',
3590
+ }),
3591
+ setData: sinon.stub(),
3592
+ setUpdatedBy: sinon.stub(),
3593
+ save: sinon.stub().resolves(),
3594
+ };
3595
+
3596
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3597
+ prerender: { allowList: ['/*'] },
3598
+ });
3599
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3600
+ // First saveMany succeeds (pattern suggestion save), second fails (covered cleanup)
3601
+ client.dataAccess.Suggestion.saveMany
3602
+ .onFirstCall().resolves()
3603
+ .onSecondCall().rejects(new Error('DB error'));
3604
+
3605
+ await client.rollbackSuggestions(
3606
+ mockSite,
3607
+ prerenderOpportunity,
3608
+ [dwSuggestion],
3609
+ { allSuggestions: [dwSuggestion, covered] },
3610
+ );
3611
+
3612
+ // Covered suggestion was mutated before saveMany
3613
+ expect(covered.setData.calledOnce).to.be.true;
3614
+ // The failure was logged as a consolidated error for alerting
3615
+ // eslint-disable-next-line max-len
3616
+ expect(log.error).to.have.been.calledWithMatch(/\[edge-rollback-failed\].*covered suggestion/);
3617
+ });
3618
+
3619
+ it('rollback marks all pattern suggestions as failed when saveMany throws', async () => {
3620
+ const prerenderOpportunity = { getId: () => 'opp-p', getType: () => 'prerender' };
3621
+ const pathA = {
3622
+ getId: () => 'path-a',
3623
+ getData: () => ({
3624
+ allowedRegexPatterns: ['/products/*'],
3625
+ edgeDeployed: Date.now(),
3626
+ }),
3627
+ setData: sinon.stub(),
3628
+ setUpdatedBy: sinon.stub(),
3629
+ save: sinon.stub().resolves(),
3630
+ };
3631
+ const pathB = {
3632
+ getId: () => 'path-b',
3633
+ getData: () => ({
3634
+ allowedRegexPatterns: ['/blog/*'],
3635
+ edgeDeployed: Date.now(),
3636
+ }),
3637
+ setData: sinon.stub(),
3638
+ setUpdatedBy: sinon.stub(),
3639
+ save: sinon.stub().resolves(),
3640
+ };
3641
+
3642
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3643
+ siteId: 'site-123',
3644
+ prerender: { allowList: ['/products/*', '/blog/*'] },
3645
+ });
3646
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3647
+ client.dataAccess.Suggestion.saveMany.rejects(new Error('DB error'));
3648
+
3649
+ const result = await client.rollbackSuggestions(
3650
+ mockSite,
3651
+ prerenderOpportunity,
3652
+ [pathA, pathB],
3653
+ );
3654
+
3655
+ // Both fail when saveMany throws
3656
+ expect(result.succeededSuggestions).to.have.length(0);
3657
+ expect(result.failedSuggestions.some((f) => f.suggestion === pathA)).to.be.true;
3658
+ expect(result.failedSuggestions.some((f) => f.suggestion === pathB)).to.be.true;
3659
+ });
3660
+
3661
+ // --- Edge case tests for deploy/rollback interaction scenarios ---
3662
+
3663
+ it('DW rollback preserves coveredByPattern on suggestions covered by both DW and path', async () => {
3664
+ // Scenario: URL covered by both DW and a path. DW rollback should
3665
+ // only strip coveredByDomainWide; coveredByPattern must survive.
3666
+ const prerenderOpportunity = {
3667
+ getId: () => 'opp-dw',
3668
+ getType: () => 'prerender',
3669
+ };
3670
+ const dwSuggestion = {
3671
+ getId: () => 'dw-1',
3672
+ getData: () => ({
3673
+ isDomainWide: true,
3674
+ allowedRegexPatterns: ['/*'],
3675
+ edgeDeployed: Date.now(),
3676
+ }),
3677
+ setData: sinon.stub(),
3678
+ setUpdatedBy: sinon.stub(),
3679
+ save: sinon.stub().resolves(),
3680
+ };
3681
+ const doubleCoveredSuggestion = {
3682
+ getId: () => 'url-1',
3683
+ getData: () => ({
3684
+ url: 'https://example.com/products/item',
3685
+ coveredByDomainWide: 'dw-1',
3686
+ coveredByPattern: 'path-1',
3687
+ }),
3688
+ setData: sinon.stub(),
3689
+ setUpdatedBy: sinon.stub(),
3690
+ save: sinon.stub().resolves(),
3691
+ };
3692
+
3693
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3694
+ prerender: { allowList: ['/*'] },
3695
+ });
3696
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3697
+
3698
+ await client.rollbackSuggestions(
3699
+ mockSite,
3700
+ prerenderOpportunity,
3701
+ [dwSuggestion],
3702
+ {
3703
+ allSuggestions: [
3704
+ dwSuggestion,
3705
+ doubleCoveredSuggestion,
3706
+ ],
3707
+ },
3708
+ );
3709
+
3710
+ expect(doubleCoveredSuggestion.setData.calledOnce).to.be.true;
3711
+ const data = doubleCoveredSuggestion.setData.firstCall.args[0];
3712
+ expect(data).to.not.have.property('coveredByDomainWide');
3713
+ expect(data).to.have.property('coveredByPattern', 'path-1');
3714
+ });
3715
+
3716
+ it('path rollback preserves coveredByDomainWide on suggestions covered by both DW and path', async () => {
3717
+ // Scenario: URL covered by both DW and a path. Path rollback should
3718
+ // only strip coveredByPattern; coveredByDomainWide must survive.
3719
+ const prerenderOpportunity = {
3720
+ getId: () => 'opp-p',
3721
+ getType: () => 'prerender',
3722
+ };
3723
+ const pathSuggestion = {
3724
+ getId: () => 'path-1',
3725
+ getData: () => ({
3726
+ allowedRegexPatterns: ['/products/*'],
3727
+ edgeDeployed: Date.now(),
3728
+ }),
3729
+ setData: sinon.stub(),
3730
+ setUpdatedBy: sinon.stub(),
3731
+ save: sinon.stub().resolves(),
3732
+ };
3733
+ const doubleCoveredSuggestion = {
3734
+ getId: () => 'url-1',
3735
+ getData: () => ({
3736
+ url: 'https://example.com/products/item',
3737
+ coveredByDomainWide: 'dw-1',
3738
+ coveredByPattern: 'path-1',
3739
+ edgeDeployed: Date.now(),
3740
+ }),
3741
+ setData: sinon.stub(),
3742
+ setUpdatedBy: sinon.stub(),
3743
+ save: sinon.stub().resolves(),
3744
+ };
3745
+
3746
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3747
+ prerender: { allowList: ['/products/*'] },
3748
+ });
3749
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3750
+
3751
+ await client.rollbackSuggestions(
3752
+ mockSite,
3753
+ prerenderOpportunity,
3754
+ [pathSuggestion],
3755
+ {
3756
+ allSuggestions: [
3757
+ pathSuggestion,
3758
+ doubleCoveredSuggestion,
3759
+ ],
3760
+ },
3761
+ );
3762
+
3763
+ expect(doubleCoveredSuggestion.setData.calledOnce).to.be.true;
3764
+ const data = doubleCoveredSuggestion.setData.firstCall.args[0];
3765
+ expect(data).to.not.have.property('coveredByPattern');
3766
+ expect(data).to.not.have.property('edgeDeployed');
3767
+ expect(data).to.have.property('coveredByDomainWide', 'dw-1');
3768
+ });
3769
+
3770
+ it('DW rollback cascade skips path deployed before DW', async () => {
3771
+ // Scenario: path deployed at t=100, DW deployed at t=200.
3772
+ // DW rollback should NOT cascade to the pre-existing path.
3773
+ const prerenderOpportunity = {
3774
+ getId: () => 'opp-dw',
3775
+ getType: () => 'prerender',
3776
+ };
3777
+ const dwSuggestion = {
3778
+ getId: () => 'dw-1',
3779
+ getData: () => ({
3780
+ isDomainWide: true,
3781
+ allowedRegexPatterns: ['/*'],
3782
+ edgeDeployed: 200,
3783
+ }),
3784
+ setData: sinon.stub(),
3785
+ setUpdatedBy: sinon.stub(),
3786
+ save: sinon.stub().resolves(),
3787
+ };
3788
+ const preExistingPath = {
3789
+ getId: () => 'path-pre',
3790
+ getData: () => ({
3791
+ allowedRegexPatterns: ['/products/*'],
3792
+ edgeDeployed: 100, // deployed BEFORE DW
3793
+ }),
3794
+ setData: sinon.stub(),
3795
+ setUpdatedBy: sinon.stub(),
3796
+ save: sinon.stub().resolves(),
3797
+ };
3798
+
3799
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3800
+ prerender: { allowList: ['/*', '/products/*'] },
3801
+ });
3802
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3803
+
3804
+ await client.rollbackSuggestions(
3805
+ mockSite,
3806
+ prerenderOpportunity,
3807
+ [dwSuggestion],
3808
+ {
3809
+ allSuggestions: [dwSuggestion, preExistingPath],
3810
+ },
3811
+ );
3812
+
3813
+ // Pre-existing path was NOT cascaded
3814
+ expect(preExistingPath.save.called).to.be.false;
3815
+ expect(preExistingPath.setData.called).to.be.false;
3816
+ });
3817
+
3818
+ it('DW rollback does NOT cascade to path deployed after DW (paths always independent)', async () => {
3819
+ // Scenario: DW deployed at t=100, path deployed at t=200.
3820
+ // Path is still independent — cascade is disabled.
3821
+ const prerenderOpportunity = {
3822
+ getId: () => 'opp-dw',
3823
+ getType: () => 'prerender',
3824
+ };
3825
+ const dwSuggestion = {
3826
+ getId: () => 'dw-1',
3827
+ getData: () => ({
3828
+ isDomainWide: true,
3829
+ allowedRegexPatterns: ['/*'],
3830
+ edgeDeployed: 100,
3831
+ }),
3832
+ setData: sinon.stub(),
3833
+ setUpdatedBy: sinon.stub(),
3834
+ save: sinon.stub().resolves(),
3835
+ };
3836
+ const laterPath = {
3837
+ getId: () => 'path-later',
3838
+ getData: () => ({
3839
+ allowedRegexPatterns: ['/blog/*'],
3840
+ edgeDeployed: 200,
3841
+ }),
3842
+ setData: sinon.stub(),
3843
+ setUpdatedBy: sinon.stub(),
3844
+ save: sinon.stub().resolves(),
3845
+ };
3846
+
3847
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3848
+ prerender: { allowList: ['/*', '/blog/*'] },
3849
+ });
3850
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3851
+
3852
+ await client.rollbackSuggestions(
3853
+ mockSite,
3854
+ prerenderOpportunity,
3855
+ [dwSuggestion],
3856
+ { allSuggestions: [dwSuggestion, laterPath] },
3857
+ );
3858
+
3859
+ // Path was NOT cascaded
3860
+ expect(laterPath.save.called).to.be.false;
3861
+ expect(laterPath.setData.called).to.be.false;
3862
+ });
3863
+
3864
+ it('DW rollback does not affect URL suggestions without coveredByDomainWide', async () => {
3865
+ // Scenario: a URL suggestion with only coveredByPattern
3866
+ // should not be touched by DW rollback.
3867
+ const prerenderOpportunity = {
3868
+ getId: () => 'opp-dw',
3869
+ getType: () => 'prerender',
3870
+ };
3871
+ const dwSuggestion = {
3872
+ getId: () => 'dw-1',
3873
+ getData: () => ({
3874
+ isDomainWide: true,
3875
+ allowedRegexPatterns: ['/*'],
3876
+ edgeDeployed: Date.now(),
3877
+ }),
3878
+ setData: sinon.stub(),
3879
+ setUpdatedBy: sinon.stub(),
3880
+ save: sinon.stub().resolves(),
3881
+ };
3882
+ const pathOnlyCovered = {
3883
+ getId: () => 'url-path-only',
3884
+ getData: () => ({
3885
+ url: 'https://example.com/products/item',
3886
+ coveredByPattern: 'path-1',
3887
+ // No coveredByDomainWide
3888
+ }),
3889
+ setData: sinon.stub(),
3890
+ setUpdatedBy: sinon.stub(),
3891
+ save: sinon.stub().resolves(),
3892
+ };
3893
+
3894
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3895
+ prerender: { allowList: ['/*'] },
3896
+ });
3897
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3898
+
3899
+ await client.rollbackSuggestions(
3900
+ mockSite,
3901
+ prerenderOpportunity,
3902
+ [dwSuggestion],
3903
+ {
3904
+ allSuggestions: [dwSuggestion, pathOnlyCovered],
3905
+ },
3906
+ );
3907
+
3908
+ // URL with only coveredByPattern was NOT touched
3909
+ expect(pathOnlyCovered.save.called).to.be.false;
3910
+ expect(pathOnlyCovered.setData.called).to.be.false;
3911
+ });
3912
+
3913
+ it('DW rollback cascade skips other domain-wide suggestions in allSuggestions', async () => {
3914
+ // Covers the isDomainWide guard inside the cascade filter.
3915
+ // A second DW suggestion in allSuggestions must not be cascaded.
3916
+ const prerenderOpportunity = {
3917
+ getId: () => 'opp-dw',
3918
+ getType: () => 'prerender',
3919
+ };
3920
+ const dwSuggestion = {
3921
+ getId: () => 'dw-1',
3922
+ getData: () => ({
3923
+ isDomainWide: true,
3924
+ allowedRegexPatterns: ['/*'],
3925
+ edgeDeployed: 100,
3926
+ }),
3927
+ setData: sinon.stub(),
3928
+ setUpdatedBy: sinon.stub(),
3929
+ save: sinon.stub().resolves(),
3930
+ };
3931
+ const otherDw = {
3932
+ getId: () => 'dw-2',
3933
+ getData: () => ({
3934
+ isDomainWide: true,
3935
+ allowedRegexPatterns: ['/*'],
3936
+ edgeDeployed: 200,
3937
+ }),
3938
+ setData: sinon.stub(),
3939
+ setUpdatedBy: sinon.stub(),
3940
+ save: sinon.stub().resolves(),
3941
+ };
3942
+
3943
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3944
+ prerender: { allowList: ['/*'] },
3945
+ });
3946
+ sinon.stub(client, 'uploadMetaconfig').resolves();
3947
+
3948
+ await client.rollbackSuggestions(
3949
+ mockSite,
3950
+ prerenderOpportunity,
3951
+ [dwSuggestion],
3952
+ { allSuggestions: [dwSuggestion, otherDw] },
3953
+ );
3954
+
3955
+ // The other DW suggestion was not cascaded
3956
+ expect(otherDw.save.called).to.be.false;
3957
+ expect(otherDw.setData.called).to.be.false;
3958
+ });
3959
+ });
3960
+
3961
+ describe('previewSuggestions', () => {
3962
+ let fetchStub;
3963
+
3964
+ beforeEach(() => {
3965
+ // Stub global fetch for HTML fetching
3966
+ fetchStub = sinon.stub(global, 'fetch');
3967
+ // Mock fetch responses for HTML fetching (warmup + actual for both original and optimized)
3968
+ fetchStub.resolves({
3969
+ ok: true,
3970
+ status: 200,
3971
+ statusText: 'OK',
3972
+ headers: {
3973
+ get: (name) => (name === 'x-edgeoptimize-cache' ? 'HIT' : null),
3974
+ },
3975
+ text: async () => '<html><body>Test HTML</body></html>',
3976
+ });
3977
+
3978
+ // Stub CDN invalidation for preview tests
3979
+ sinon.stub(client, 'invalidateCdnCache').resolves({
3980
+ status: 'success',
3981
+ provider: 'cloudfront',
3982
+ invalidationId: 'I123',
3983
+ });
3984
+
3985
+ // Stub fetchConfig to return null by default (no existing config)
3986
+ sinon.stub(client, 'fetchConfig').resolves(null);
3987
+
3988
+ // Stub fetchMetaconfig to return metaconfig with apiKeys array
3989
+ sinon.stub(client, 'fetchMetaconfig').resolves({
3990
+ siteId: 'site-123',
3991
+ apiKeys: ['test-api-key-1', 'test-api-key-2'],
3992
+ });
3993
+
3994
+ // Add TOKOWAKA_EDGE_URL to env
3995
+ client.env.TOKOWAKA_EDGE_URL = 'https://edge-dev.tokowaka.now';
3996
+ });
3997
+
3998
+ afterEach(() => {
3999
+ // fetchStub will be restored by global afterEach sinon.restore()
4000
+ // Just clean up env changes
4001
+ delete client.env.TOKOWAKA_EDGE_URL;
4002
+ });
4003
+
4004
+ it('should preview suggestions successfully with HTML', async () => {
4005
+ const result = await client.previewSuggestions(
4006
+ mockSite,
4007
+ mockOpportunity,
4008
+ mockSuggestions,
4009
+ { warmupDelayMs: 0 },
4010
+ );
4011
+
4012
+ expect(result).to.have.property('s3Path', 'preview/opportunities/example.com/L3BhZ2Ux');
4013
+ expect(result).to.have.property('succeededSuggestions');
4014
+ expect(result.succeededSuggestions).to.have.length(2);
4015
+ expect(result).to.have.property('failedSuggestions');
4016
+ expect(result.failedSuggestions).to.have.length(0);
4017
+ expect(result).to.have.property('html');
4018
+ expect(result.html).to.have.property('url', 'https://example.com/page1');
4019
+ expect(result.html).to.have.property('originalHtml');
4020
+ expect(result.html).to.have.property('optimizedHtml');
4021
+ expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
4022
+ expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
4023
+
4024
+ // Verify fetch was called for HTML fetching
4025
+ // (4 times: warmup + actual for original and optimized)
4026
+ expect(fetchStub.callCount).to.equal(4);
4027
+ expect(s3Client.send).to.have.been.calledOnce;
4028
+ });
4029
+
4030
+ it('should set applyStale to true for all preview patches', async () => {
4031
+ // Create a scenario with existing deployed patches
4032
+ client.fetchConfig.resolves({
4033
+ url: 'https://example.com/page1',
4034
+ version: '1.0',
4035
+ forceFail: false,
4036
+ prerender: true,
4037
+ patches: [
4038
+ {
4039
+ op: 'replace',
4040
+ selector: 'h3',
4041
+ value: 'Existing Heading',
4042
+ opportunityId: 'opp-999',
4043
+ suggestionId: 'sugg-999',
4044
+ prerenderRequired: true,
4045
+ lastUpdated: 1234567890,
4046
+ },
4047
+ ],
4048
+ });
4049
+
4050
+ const result = await client.previewSuggestions(
4051
+ mockSite,
4052
+ mockOpportunity,
4053
+ mockSuggestions,
4054
+ { warmupDelayMs: 0 },
4055
+ );
4056
+
4057
+ expect(result.config).to.exist;
4058
+ expect(result.config.patches).to.have.length(3); // 2 new + 1 existing
4059
+
4060
+ // Verify that all new preview patches have applyStale: true
4061
+ const newPatches = result.config.patches.filter(
4062
+ (p) => p.suggestionId === 'sugg-1' || p.suggestionId === 'sugg-2',
4063
+ );
4064
+ expect(newPatches).to.have.length(2);
4065
+ newPatches.forEach((patch) => {
4066
+ expect(patch.applyStale).to.equal(true);
4067
+ });
4068
+
4069
+ // Existing patch should not have applyStale field
4070
+ const existingPatch = result.config.patches.find((p) => p.suggestionId === 'sugg-999');
4071
+ expect(existingPatch).to.exist;
4072
+ expect(existingPatch.applyStale).to.be.undefined;
4073
+ });
4074
+
4075
+ it('should preview prerender-only suggestions with no patches', async () => {
4076
+ // Update fetchConfig to return existing config with deployed patches
4077
+ client.fetchConfig.resolves({
4078
+ url: 'https://example.com/page1',
4079
+ version: '1.0',
4080
+ forceFail: false,
4081
+ prerender: false,
4082
+ patches: [
4083
+ {
4084
+ op: 'replace',
4085
+ selector: 'h1',
4086
+ value: 'Existing Heading',
4087
+ opportunityId: 'opp-other-123',
4088
+ suggestionId: 'sugg-other',
4089
+ prerenderRequired: false,
4090
+ lastUpdated: 1234567890,
4091
+ },
4092
+ ],
4093
+ });
4094
+
4095
+ // Create prerender opportunity
4096
+ const prerenderOpportunity = {
4097
+ getId: () => 'opp-prerender-123',
4098
+ getType: () => 'prerender',
4099
+ };
4100
+
4101
+ // Create prerender suggestions with no transform rules (prerender-only)
4102
+ const prerenderSuggestions = [
4103
+ {
4104
+ getId: () => 'prerender-sugg-1',
4105
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
4106
+ getData: () => ({
4107
+ url: 'https://example.com/page1',
4108
+ // No transform rules - prerender only
4109
+ }),
4110
+ },
4111
+ ];
4112
+
4113
+ const result = await client.previewSuggestions(
4114
+ mockSite,
4115
+ prerenderOpportunity,
4116
+ prerenderSuggestions,
4117
+ { warmupDelayMs: 0 },
4118
+ );
4119
+
4120
+ expect(result).to.have.property('s3Path');
4121
+ expect(result.config).to.not.be.null;
4122
+ expect(result.config.patches).to.have.length(1); // Merged with existing deployed patch
4123
+ expect(result.config.prerender).to.equal(true); // Prerender enabled
4124
+ expect(result.succeededSuggestions).to.have.length(1);
4125
+ expect(result.failedSuggestions).to.have.length(0);
4126
+ expect(result).to.have.property('html');
4127
+
4128
+ // Verify fetch was called for HTML fetching
4129
+ expect(fetchStub.callCount).to.equal(4);
4130
+ expect(s3Client.send).to.have.been.calledOnce;
4131
+ });
4132
+
4133
+ it('should throw error if TOKOWAKA_EDGE_URL is not configured', async () => {
4134
+ delete client.env.TOKOWAKA_EDGE_URL;
4135
+
4136
+ try {
4137
+ await client.previewSuggestions(mockSite, mockOpportunity, mockSuggestions);
4138
+ expect.fail('Should have thrown error');
4139
+ } catch (error) {
4140
+ expect(error.message).to.include('TOKOWAKA_EDGE_URL is required for preview');
4141
+ expect(error.status).to.equal(500);
4142
+ }
4143
+ });
4144
+
4145
+ it('should preview suggestions successfully without metaconfig (optional)', async () => {
4146
+ // Override the stub from beforeEach to return null
4147
+ client.fetchMetaconfig.resolves(null);
4148
+
4149
+ const result = await client.previewSuggestions(
4150
+ mockSite,
4151
+ mockOpportunity,
4152
+ mockSuggestions,
4153
+ { warmupDelayMs: 0 },
4154
+ );
4155
+
4156
+ expect(result.succeededSuggestions).to.have.length(2);
4157
+ expect(result.failedSuggestions).to.have.length(0);
4158
+ expect(result.config).to.exist;
4159
+ expect(result.html).to.exist;
4160
+ expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
4161
+ expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
4162
+
4163
+ // Verify fetch was called without API key (undefined)
4164
+ expect(fetchStub.callCount).to.equal(4); // 2 warmup + 2 actual (original + optimized)
4165
+ });
4166
+
4167
+ it('should preview suggestions successfully without apiKeys in metaconfig (optional)', async () => {
4168
+ // Override the stub from beforeEach
4169
+ client.fetchMetaconfig.resolves({
4170
+ siteId: 'site-123',
4171
+ // apiKeys missing - should work without it
4172
+ });
4173
+
4174
+ const result = await client.previewSuggestions(
4175
+ mockSite,
4176
+ mockOpportunity,
4177
+ mockSuggestions,
4178
+ { warmupDelayMs: 0 },
4179
+ );
4180
+
4181
+ expect(result.succeededSuggestions).to.have.length(2);
4182
+ expect(result.failedSuggestions).to.have.length(0);
4183
+ expect(result.config).to.exist;
4184
+ expect(result.html).to.exist;
4185
+ expect(result.html.originalHtml).to.equal('<html><body>Test HTML</body></html>');
4186
+ expect(result.html.optimizedHtml).to.equal('<html><body>Test HTML</body></html>');
4187
+ expect(fetchStub.callCount).to.equal(4);
4188
+ });
4189
+
4190
+ it('should preview suggestions successfully with empty apiKeys array (optional)', async () => {
4191
+ // Override the stub from beforeEach
4192
+ client.fetchMetaconfig.resolves({
4193
+ siteId: 'site-123',
4194
+ apiKeys: [],
3183
4195
  });
3184
4196
 
3185
4197
  const result = await client.previewSuggestions(
@@ -3594,34 +4606,6 @@ describe('TokowakaClient', () => {
3594
4606
  expect(log.warn).to.have.been.calledWith('No CDN providers specified for cache invalidation');
3595
4607
  });
3596
4608
 
3597
- it('should return error object if no CDN client available', async () => {
3598
- client.cdnClientRegistry.getClient.returns(null);
3599
-
3600
- const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
3601
-
3602
- expect(result).to.be.an('array');
3603
- expect(result).to.have.lengthOf(1);
3604
- expect(result[0]).to.deep.equal({
3605
- status: 'error',
3606
- provider: 'cloudfront',
3607
- message: 'No CDN client available for provider: cloudfront',
3608
- });
3609
- });
3610
-
3611
- it('should return error object if CDN invalidation fails', async () => {
3612
- mockCdnClient.invalidateCache.rejects(new Error('CDN API error'));
3613
-
3614
- const result = await client.invalidateCdnCache({ urls: ['https://example.com/page1'], providers: 'cloudfront' });
3615
-
3616
- expect(result).to.be.an('array');
3617
- expect(result).to.have.lengthOf(1);
3618
- expect(result[0]).to.deep.equal({
3619
- status: 'error',
3620
- provider: 'cloudfront',
3621
- message: 'CDN API error',
3622
- });
3623
- });
3624
-
3625
4609
  it('should handle multiple providers in parallel', async () => {
3626
4610
  const mockFastlyClient = {
3627
4611
  invalidateCache: sinon.stub().resolves({
@@ -3813,6 +4797,7 @@ describe('TokowakaClient', () => {
3813
4797
  previewBucketName: 'test-preview-bucket',
3814
4798
  s3Client: { send: sinon.stub().resolves() },
3815
4799
  env,
4800
+ dataAccess: { Suggestion: { saveMany: sinon.stub().resolves() } },
3816
4801
  },
3817
4802
  log,
3818
4803
  );
@@ -4364,6 +5349,7 @@ describe('TokowakaClient', () => {
4364
5349
  TOKOWAKA_CDN_PROVIDER: 'cloudfront',
4365
5350
  TOKOWAKA_CDN_CONFIG: JSON.stringify({ cloudfront: { distributionId: 'E123456', region: 'us-east-1' } }),
4366
5351
  },
5352
+ dataAccess: { Suggestion: { saveMany: sinon.stub().resolves() } },
4367
5353
  },
4368
5354
  log,
4369
5355
  );
@@ -4571,7 +5557,7 @@ describe('TokowakaClient', () => {
4571
5557
  expect(result.succeededSuggestions).to.have.length(2);
4572
5558
  expect(result.failedSuggestions).to.have.length(0);
4573
5559
  expect(result.coveredSuggestions).to.have.length(0);
4574
- expect(s1.save).to.have.been.called;
5560
+ expect(s1.getData()).to.have.property('edgeDeployed');
4575
5561
  expect(s1.getUpdatedBy()).to.equal('test-user');
4576
5562
  });
4577
5563
 
@@ -4593,7 +5579,7 @@ describe('TokowakaClient', () => {
4593
5579
 
4594
5580
  expect(result.succeededSuggestions).to.have.length(1);
4595
5581
  expect(deploySuggestionsStub).to.have.been.calledOnce;
4596
- expect(s1.save).to.have.been.called;
5582
+ expect(s1.getData()).to.have.property('edgeDeployed');
4597
5583
  });
4598
5584
 
4599
5585
  it('should clear edgeOptimizeStatus STALE when deploying', async () => {
@@ -4754,15 +5740,19 @@ describe('TokowakaClient', () => {
4754
5740
  expect(result.coveredSuggestions).to.not.include(approved);
4755
5741
  });
4756
5742
 
4757
- it('should warn but continue when covered-suggestion save fails', async () => {
5743
+ it('should warn but continue when covered-suggestion saveMany fails', async () => {
4758
5744
  const dw = makeSuggestion('dw1', {
4759
5745
  isDomainWide: true,
4760
5746
  allowedRegexPatterns: ['^https://example\\.com/.*'],
4761
5747
  });
4762
5748
  const covered = makeSuggestion('covered1', { url: 'https://example.com/page1' });
4763
- covered.save = sinon.stub().rejects(new Error('DB error'));
4764
5749
 
4765
5750
  fetchMetaconfigStub.resolves({ siteId: 'site-123' });
5751
+ // First saveMany call is for covered suggestions (fails),
5752
+ // second is for pattern suggestions batch save (succeeds)
5753
+ client.dataAccess.Suggestion.saveMany
5754
+ .onFirstCall().rejects(new Error('DB error'))
5755
+ .onSecondCall().resolves();
4766
5756
 
4767
5757
  const result = await client.deployToEdge({
4768
5758
  site: mockSite,
@@ -4773,7 +5763,7 @@ describe('TokowakaClient', () => {
4773
5763
 
4774
5764
  // domain-wide itself still succeeded
4775
5765
  expect(result.succeededSuggestions).to.include(dw);
4776
- // covered is not in either list the error was swallowed with a warning
5766
+ // covered is not in coveredSuggestionssaveMany error was swallowed
4777
5767
  expect(result.coveredSuggestions).to.not.include(covered);
4778
5768
  expect(log.warn).to.have.been.called;
4779
5769
  });
@@ -4797,7 +5787,7 @@ describe('TokowakaClient', () => {
4797
5787
  expect(result.succeededSuggestions).to.not.include(dw);
4798
5788
  expect(result.failedSuggestions).to.have.length(1);
4799
5789
  expect(result.failedSuggestions[0].statusCode).to.equal(500);
4800
- expect(result.failedSuggestions[0].reason).to.equal('upload failed');
5790
+ expect(result.failedSuggestions[0].reason).to.equal('Internal server error');
4801
5791
  });
4802
5792
 
4803
5793
  it('should skip invalid regex in same-batch pattern filtering without throwing', async () => {
@@ -4838,6 +5828,24 @@ describe('TokowakaClient', () => {
4838
5828
  expect(uploadMetaconfigStub).to.not.have.been.called;
4839
5829
  });
4840
5830
 
5831
+ it('should warn and skip empty string patterns', async () => {
5832
+ const path = makeSuggestion('p1', {
5833
+ allowedRegexPatterns: [''],
5834
+ });
5835
+
5836
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
5837
+
5838
+ const result = await client.deployToEdge({
5839
+ site: mockSite,
5840
+ opportunity: mockOpportunity,
5841
+ targetSuggestions: [path],
5842
+ allSuggestions: [path],
5843
+ });
5844
+
5845
+ expect(result.succeededSuggestions).to.include(path);
5846
+ expect(log.warn).to.have.been.called;
5847
+ });
5848
+
4841
5849
  it('should warn and skip invalid regex patterns for domain-wide', async () => {
4842
5850
  const dw = makeSuggestion('dw1', {
4843
5851
  isDomainWide: true,
@@ -4881,15 +5889,38 @@ describe('TokowakaClient', () => {
4881
5889
  expect(deploySuggestionsStub).to.not.have.been.called;
4882
5890
  });
4883
5891
 
5892
+ it('same-batch skip with path-level pattern sets coveredByPattern, not coveredByDomainWide', async () => {
5893
+ const path = makeSuggestion('p1', {
5894
+ allowedRegexPatterns: ['/products/*'],
5895
+ });
5896
+ const regular = makeSuggestion('r1', { url: 'https://example.com/products/item' });
5897
+
5898
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
5899
+
5900
+ const result = await client.deployToEdge({
5901
+ site: mockSite,
5902
+ opportunity: mockOpportunity,
5903
+ targetSuggestions: [path, regular],
5904
+ allSuggestions: [path, regular],
5905
+ });
5906
+
5907
+ expect(result.coveredSuggestions).to.include(regular);
5908
+ expect(regular.getData()).to.have.property('coveredByPattern', 'same-batch-deployment');
5909
+ expect(regular.getData()).to.not.have.property('coveredByDomainWide');
5910
+ expect(regular.getData()).to.have.property('skippedInDeployment', true);
5911
+ expect(deploySuggestionsStub).to.not.have.been.called;
5912
+ });
5913
+
4884
5914
  it('should surface same-batch save failures as failed suggestions with statusCode 500', async () => {
4885
5915
  const dw = makeSuggestion('dw1', {
4886
5916
  isDomainWide: true,
4887
5917
  allowedRegexPatterns: ['^https://example\\.com/.*'],
4888
5918
  });
4889
5919
  const regular = makeSuggestion('r1', { url: 'https://example.com/page1' });
4890
- regular.save = sinon.stub().rejects(new Error('save failed'));
4891
5920
 
4892
5921
  fetchMetaconfigStub.resolves({ siteId: 'site-123' });
5922
+ // Make saveMany fail for same-batch skipped suggestions
5923
+ client.dataAccess.Suggestion.saveMany.rejects(new Error('save failed'));
4893
5924
 
4894
5925
  const result = await client.deployToEdge({
4895
5926
  site: mockSite,
@@ -4899,8 +5930,8 @@ describe('TokowakaClient', () => {
4899
5930
  });
4900
5931
 
4901
5932
  expect(result.failedSuggestions.some((f) => f.suggestion === regular)).to.be.true;
4902
- expect(result.failedSuggestions.find((f) => f.suggestion === regular).statusCode)
4903
- .to.equal(500);
5933
+ // eslint-disable-next-line max-len
5934
+ expect(result.failedSuggestions.find((f) => f.suggestion === regular).statusCode).to.equal(500);
4904
5935
  expect(result.coveredSuggestions).to.not.include(regular);
4905
5936
  expect(log.warn).to.have.been.called;
4906
5937
  });
@@ -4990,5 +6021,320 @@ describe('TokowakaClient', () => {
4990
6021
  const skippedInCovered = result.coveredSuggestions.filter((s) => s.getId() === 'r1');
4991
6022
  expect(skippedInCovered).to.have.length(1);
4992
6023
  });
6024
+
6025
+ it('domain-wide deploy merges /* into existing allowList instead of replacing it', async () => {
6026
+ const dw = makeSuggestion('dw1', {
6027
+ isDomainWide: true,
6028
+ allowedRegexPatterns: ['/*'],
6029
+ });
6030
+
6031
+ fetchMetaconfigStub.resolves({ siteId: 'site-123', prerender: { allowList: ['/products/*'] } });
6032
+
6033
+ await client.deployToEdge({
6034
+ site: mockSite,
6035
+ opportunity: mockOpportunity,
6036
+ targetSuggestions: [dw],
6037
+ allSuggestions: [dw],
6038
+ });
6039
+
6040
+ const uploadedConfig = uploadMetaconfigStub.firstCall.args[1];
6041
+ expect(uploadedConfig.prerender.allowList).to.deep.equal(['/products/*', '/*']);
6042
+ });
6043
+
6044
+ it('domain-wide deploy deduplicates patterns already in allowList', async () => {
6045
+ const dw = makeSuggestion('dw1', {
6046
+ isDomainWide: true,
6047
+ allowedRegexPatterns: ['/*'],
6048
+ });
6049
+
6050
+ fetchMetaconfigStub.resolves({ siteId: 'site-123', prerender: { allowList: ['/*'] } });
6051
+
6052
+ await client.deployToEdge({
6053
+ site: mockSite,
6054
+ opportunity: mockOpportunity,
6055
+ targetSuggestions: [dw],
6056
+ allSuggestions: [dw],
6057
+ });
6058
+
6059
+ expect(uploadMetaconfigStub).to.not.have.been.called;
6060
+ });
6061
+
6062
+ it('path deploy appends /products/* to an empty allowList', async () => {
6063
+ const path = makeSuggestion('p1', {
6064
+ allowedRegexPatterns: ['/products/*'],
6065
+ });
6066
+
6067
+ fetchMetaconfigStub.resolves(null);
6068
+
6069
+ const result = await client.deployToEdge({
6070
+ site: mockSite,
6071
+ opportunity: mockOpportunity,
6072
+ targetSuggestions: [path],
6073
+ allSuggestions: [path],
6074
+ });
6075
+
6076
+ expect(result.succeededSuggestions).to.include(path);
6077
+ const uploadedConfig = uploadMetaconfigStub.firstCall.args[1];
6078
+ expect(uploadedConfig.prerender.allowList).to.deep.equal(['/products/*']);
6079
+ expect(path.getData()).to.have.property('edgeDeployed');
6080
+ });
6081
+
6082
+ it('path deploy appends /products/* when domain-wide /* is already in allowList', async () => {
6083
+ const path = makeSuggestion('p1', {
6084
+ allowedRegexPatterns: ['/products/*'],
6085
+ });
6086
+
6087
+ fetchMetaconfigStub.resolves({ siteId: 'site-123', prerender: { allowList: ['/*'] } });
6088
+
6089
+ await client.deployToEdge({
6090
+ site: mockSite,
6091
+ opportunity: mockOpportunity,
6092
+ targetSuggestions: [path],
6093
+ allSuggestions: [path],
6094
+ });
6095
+
6096
+ const uploadedConfig = uploadMetaconfigStub.firstCall.args[1];
6097
+ expect(uploadedConfig.prerender.allowList).to.deep.equal(['/*', '/products/*']);
6098
+ });
6099
+
6100
+ it('path deploy skips CDN write but still marks edgeDeployed when pattern already in allowList', async () => {
6101
+ const path = makeSuggestion('p1', {
6102
+ allowedRegexPatterns: ['/products/*'],
6103
+ });
6104
+
6105
+ fetchMetaconfigStub.resolves({ siteId: 'site-123', prerender: { allowList: ['/products/*'] } });
6106
+
6107
+ const result = await client.deployToEdge({
6108
+ site: mockSite,
6109
+ opportunity: mockOpportunity,
6110
+ targetSuggestions: [path],
6111
+ allSuggestions: [path],
6112
+ });
6113
+
6114
+ expect(uploadMetaconfigStub).to.not.have.been.called;
6115
+ expect(result.succeededSuggestions).to.include(path);
6116
+ expect(path.getData()).to.have.property('edgeDeployed');
6117
+ });
6118
+
6119
+ it('path deploy does not call deploySuggestions (no per-URL S3 config)', async () => {
6120
+ const path = makeSuggestion('p1', {
6121
+ allowedRegexPatterns: ['/products/*'],
6122
+ });
6123
+
6124
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6125
+
6126
+ await client.deployToEdge({
6127
+ site: mockSite,
6128
+ opportunity: mockOpportunity,
6129
+ targetSuggestions: [path],
6130
+ allSuggestions: [path],
6131
+ });
6132
+
6133
+ expect(deploySuggestionsStub).to.not.have.been.called;
6134
+ });
6135
+
6136
+ it('path deploy marks per-URL suggestions under the deployed path prefix as covered', async () => {
6137
+ const path = makeSuggestion('p1', {
6138
+ allowedRegexPatterns: ['/products/*'],
6139
+ });
6140
+ const urlUnderPath = makeSuggestion('u1', { url: 'https://example.com/products/item-1' });
6141
+ const urlElsewhere = makeSuggestion('u2', { url: 'https://example.com/about' });
6142
+
6143
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6144
+
6145
+ const result = await client.deployToEdge({
6146
+ site: mockSite,
6147
+ opportunity: mockOpportunity,
6148
+ targetSuggestions: [path],
6149
+ allSuggestions: [path, urlUnderPath, urlElsewhere],
6150
+ });
6151
+
6152
+ expect(result.coveredSuggestions).to.include(urlUnderPath);
6153
+ expect(urlUnderPath.getData()).to.have.property('coveredByPattern', 'p1');
6154
+ expect(result.coveredSuggestions).to.not.include(urlElsewhere);
6155
+ });
6156
+
6157
+ it('path deploy marks as failed with statusCode 500 when metaconfig upload fails', async () => {
6158
+ const path = makeSuggestion('p1', {
6159
+ allowedRegexPatterns: ['/products/*'],
6160
+ });
6161
+
6162
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6163
+ uploadMetaconfigStub.rejects(new Error('upload failed'));
6164
+
6165
+ const result = await client.deployToEdge({
6166
+ site: mockSite,
6167
+ opportunity: mockOpportunity,
6168
+ targetSuggestions: [path],
6169
+ allSuggestions: [path],
6170
+ });
6171
+
6172
+ expect(result.succeededSuggestions).to.not.include(path);
6173
+ expect(result.failedSuggestions).to.have.length(1);
6174
+ expect(result.failedSuggestions[0].statusCode).to.equal(500);
6175
+ expect(result.failedSuggestions[0].reason).to.equal('Internal server error');
6176
+ });
6177
+
6178
+ it('path deploy marks coverage via allowedRegexPatterns even when pathPattern field is absent', async () => {
6179
+ const path = makeSuggestion('p1', {
6180
+ allowedRegexPatterns: ['/products/*'],
6181
+ // no pathPattern field — coverage is derived from allowedRegexPatterns
6182
+ });
6183
+ const urlUnderPath = makeSuggestion('u1', { url: 'https://example.com/products/item-1' });
6184
+
6185
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6186
+
6187
+ const result = await client.deployToEdge({
6188
+ site: mockSite,
6189
+ opportunity: mockOpportunity,
6190
+ targetSuggestions: [path],
6191
+ allSuggestions: [path, urlUnderPath],
6192
+ });
6193
+
6194
+ expect(result.succeededSuggestions).to.include(path);
6195
+ expect(result.coveredSuggestions).to.include(urlUnderPath);
6196
+ expect(urlUnderPath.getData()).to.have.property('coveredByPattern', 'p1');
6197
+ });
6198
+
6199
+ it('path deploy does not mark non-NEW, pattern, or already-deployed candidates as covered', async () => {
6200
+ const path = makeSuggestion('p1', {
6201
+ allowedRegexPatterns: ['/products/*'],
6202
+ });
6203
+ // APPROVED — not a deployable status
6204
+ const nonNew = makeSuggestion('u1', { url: 'https://example.com/products/a' }, 'APPROVED');
6205
+ // isDomainWide pattern — excluded (isPatternSuggestion)
6206
+ const dw = makeSuggestion('u2', { isDomainWide: true, allowedRegexPatterns: ['/*'], url: 'https://example.com/products/b' });
6207
+ // another path pattern — excluded (isPatternSuggestion)
6208
+ const otherPath = makeSuggestion('u3', { allowedRegexPatterns: ['/products/*'], url: 'https://example.com/products/c' });
6209
+ // already edgeDeployed — excluded
6210
+ const deployed = makeSuggestion('u4', { url: 'https://example.com/products/d', edgeDeployed: 12345 });
6211
+
6212
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6213
+
6214
+ const result = await client.deployToEdge({
6215
+ site: mockSite,
6216
+ opportunity: mockOpportunity,
6217
+ targetSuggestions: [path],
6218
+ allSuggestions: [path, nonNew, dw, otherPath, deployed],
6219
+ });
6220
+
6221
+ expect(result.coveredSuggestions).to.have.length(0);
6222
+ });
6223
+
6224
+ it('path deploy silently skips covered-suggestion candidates with invalid URLs', async () => {
6225
+ const path = makeSuggestion('p1', {
6226
+ allowedRegexPatterns: ['/products/*'],
6227
+ });
6228
+ const badUrl = makeSuggestion('u1', { url: 'not-a-valid-url' });
6229
+
6230
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6231
+
6232
+ const result = await client.deployToEdge({
6233
+ site: mockSite,
6234
+ opportunity: mockOpportunity,
6235
+ targetSuggestions: [path],
6236
+ allSuggestions: [path, badUrl],
6237
+ });
6238
+
6239
+ // bad URL is not covered, but deploy itself still succeeds
6240
+ expect(result.succeededSuggestions).to.include(path);
6241
+ expect(result.coveredSuggestions).to.not.include(badUrl);
6242
+ });
6243
+
6244
+ it('path deploy warns but continues when covered-suggestion saveMany fails', async () => {
6245
+ const path = makeSuggestion('p1', {
6246
+ allowedRegexPatterns: ['/products/*'],
6247
+ });
6248
+ const urlUnderPath = makeSuggestion('u1', { url: 'https://example.com/products/item-1' });
6249
+
6250
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6251
+ // First saveMany call is for covered suggestions (fails),
6252
+ // second is for pattern suggestions batch save (succeeds)
6253
+ client.dataAccess.Suggestion.saveMany
6254
+ .onFirstCall().rejects(new Error('DB error'))
6255
+ .onSecondCall().resolves();
6256
+
6257
+ const result = await client.deployToEdge({
6258
+ site: mockSite,
6259
+ opportunity: mockOpportunity,
6260
+ targetSuggestions: [path],
6261
+ allSuggestions: [path, urlUnderPath],
6262
+ });
6263
+
6264
+ // path suggestion itself still succeeded
6265
+ expect(result.succeededSuggestions).to.include(path);
6266
+ // covered is not in coveredSuggestions — saveMany error was swallowed
6267
+ expect(result.coveredSuggestions).to.not.include(urlUnderPath);
6268
+ expect(log.warn).to.have.been.called;
6269
+ });
6270
+
6271
+ it('batch filter is a no-op when all pattern matchers are invalid', async () => {
6272
+ const invalidPattern = {
6273
+ getId: () => 'bad-pattern',
6274
+ getStatus: () => 'NEW',
6275
+ getData: () => ({
6276
+ allowedRegexPatterns: [''],
6277
+ url: 'https://example.com/*',
6278
+ }),
6279
+ setData: sinon.stub(),
6280
+ setUpdatedBy: sinon.stub(),
6281
+ save: sinon.stub().resolves(),
6282
+ };
6283
+ const perUrl = {
6284
+ getId: () => 'url-1',
6285
+ getStatus: () => 'NEW',
6286
+ getData: () => ({
6287
+ url: 'https://example.com/page1',
6288
+ contentGainRatio: 1.5,
6289
+ wordCountBefore: 100,
6290
+ wordCountAfter: 200,
6291
+ }),
6292
+ setData: sinon.stub(),
6293
+ setUpdatedBy: sinon.stub(),
6294
+ save: sinon.stub().resolves(),
6295
+ };
6296
+
6297
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6298
+ deploySuggestionsStub.resolves({
6299
+ succeededSuggestions: [perUrl],
6300
+ failedSuggestions: [],
6301
+ });
6302
+
6303
+ const result = await client.deployToEdge({
6304
+ site: mockSite,
6305
+ opportunity: mockOpportunity,
6306
+ targetSuggestions: [invalidPattern, perUrl],
6307
+ allSuggestions: [invalidPattern, perUrl],
6308
+ });
6309
+
6310
+ // per-URL not skipped (invalid matchers → no filtering)
6311
+ expect(result.succeededSuggestions).to.include(perUrl);
6312
+ expect(result.succeededSuggestions).to.include(invalidPattern);
6313
+ });
6314
+
6315
+ it('should surface pattern suggestion saveMany failure', async () => {
6316
+ const dw = makeSuggestion('dw1', {
6317
+ isDomainWide: true,
6318
+ allowedRegexPatterns: ['^https://example\\.com/.*'],
6319
+ });
6320
+
6321
+ fetchMetaconfigStub.resolves({ siteId: 'site-123' });
6322
+ // Make saveMany fail for pattern suggestion batch save
6323
+ client.dataAccess.Suggestion.saveMany.rejects(new Error('DB error'));
6324
+
6325
+ const result = await client.deployToEdge({
6326
+ site: mockSite,
6327
+ opportunity: mockOpportunity,
6328
+ targetSuggestions: [dw],
6329
+ allSuggestions: [dw],
6330
+ });
6331
+
6332
+ expect(result.succeededSuggestions).to.not.include(dw);
6333
+ expect(result.failedSuggestions).to.have.length(1);
6334
+ expect(result.failedSuggestions[0].suggestion).to.equal(dw);
6335
+ expect(result.failedSuggestions[0].statusCode).to.equal(500);
6336
+ expect(result.failedSuggestions[0].reason).to.equal('Internal server error');
6337
+ expect(log.error).to.have.been.called;
6338
+ });
4993
6339
  });
4994
6340
  });