@adobe/spacecat-shared-tokowaka-client 1.17.2 → 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.
- package/CHANGELOG.md +12 -0
- package/package.json +4 -4
- package/src/index.js +450 -236
- package/src/utils/metaconfig-utils.js +52 -0
- package/src/utils/pattern-utils.js +43 -0
- package/src/utils/suggestion-utils.js +174 -0
- package/test/index.test.js +1575 -229
- package/test/utils/suggestion-utils.test.js +43 -1
package/test/index.test.js
CHANGED
|
@@ -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
|
-
|
|
2974
|
-
|
|
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
|
-
|
|
2986
|
+
prerender: { allowList: ['/*', '/products/*'] },
|
|
2980
2987
|
});
|
|
2988
|
+
const uploadStub = sinon.stub(client, 'uploadMetaconfig').resolves();
|
|
2981
2989
|
|
|
2982
|
-
|
|
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
|
-
|
|
2996
|
-
|
|
2997
|
-
{ warmupDelayMs: 0 },
|
|
2992
|
+
prerenderOpportunity,
|
|
2993
|
+
[pathSuggestion],
|
|
2998
2994
|
);
|
|
2999
2995
|
|
|
3000
|
-
expect(result).to.
|
|
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
|
-
|
|
3006
|
-
expect(
|
|
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('
|
|
3019
|
-
|
|
3020
|
-
|
|
3021
|
-
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
3026
|
-
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
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.
|
|
3021
|
+
const result = await client.rollbackSuggestions(
|
|
3039
3022
|
mockSite,
|
|
3040
|
-
|
|
3041
|
-
|
|
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
|
-
|
|
3058
|
-
const
|
|
3059
|
-
expect(
|
|
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('
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
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
|
-
|
|
3090
|
-
|
|
3091
|
-
{
|
|
3092
|
-
|
|
3093
|
-
|
|
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.
|
|
3052
|
+
const result = await client.rollbackSuggestions(
|
|
3102
3053
|
mockSite,
|
|
3103
3054
|
prerenderOpportunity,
|
|
3104
|
-
|
|
3105
|
-
{ warmupDelayMs: 0 },
|
|
3055
|
+
[dwSuggestion],
|
|
3106
3056
|
);
|
|
3107
3057
|
|
|
3108
|
-
expect(result).to.
|
|
3109
|
-
|
|
3110
|
-
expect(
|
|
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('
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
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
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
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.
|
|
3095
|
+
const result = await client.rollbackSuggestions(
|
|
3138
3096
|
mockSite,
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
{
|
|
3097
|
+
prerenderOpportunity,
|
|
3098
|
+
[dwSuggestion],
|
|
3099
|
+
{ allSuggestions: [dwSuggestion, coveredSuggestion], updatedBy: 'test@example.com' },
|
|
3142
3100
|
);
|
|
3143
3101
|
|
|
3144
|
-
expect(result.succeededSuggestions).to.
|
|
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
|
|
3152
|
-
expect(
|
|
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('
|
|
3156
|
-
|
|
3157
|
-
|
|
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
|
-
|
|
3146
|
+
prerender: { allowList: ['/products/*'] },
|
|
3160
3147
|
});
|
|
3148
|
+
sinon.stub(client, 'uploadMetaconfig').resolves();
|
|
3161
3149
|
|
|
3162
|
-
|
|
3150
|
+
await client.rollbackSuggestions(
|
|
3163
3151
|
mockSite,
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
{
|
|
3152
|
+
prerenderOpportunity,
|
|
3153
|
+
[pathSuggestion],
|
|
3154
|
+
{ allSuggestions: [pathSuggestion, coveredSuggestion] },
|
|
3167
3155
|
);
|
|
3168
3156
|
|
|
3169
|
-
expect(
|
|
3170
|
-
|
|
3171
|
-
expect(
|
|
3172
|
-
expect(
|
|
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('
|
|
3179
|
-
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
5766
|
+
// covered is not in coveredSuggestions — saveMany 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('
|
|
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
|
-
|
|
4903
|
-
|
|
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
|
});
|