@adobe/spacecat-shared-tokowaka-client 1.1.1 → 1.2.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.
@@ -233,7 +233,7 @@ describe('HeadingsMapper', () => {
233
233
  });
234
234
  });
235
235
 
236
- describe('suggestionToPatch', () => {
236
+ describe('suggestionsToPatches', () => {
237
237
  it('should create patch for heading-empty with transformRules', () => {
238
238
  const suggestion = {
239
239
  getId: () => 'sugg-123',
@@ -248,7 +248,9 @@ describe('HeadingsMapper', () => {
248
248
  }),
249
249
  };
250
250
 
251
- const patch = mapper.suggestionToPatch(suggestion, 'opp-123');
251
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-123');
252
+ expect(patches.length).to.equal(1);
253
+ const patch = patches[0];
252
254
 
253
255
  expect(patch).to.deep.include({
254
256
  op: 'replace',
@@ -275,7 +277,9 @@ describe('HeadingsMapper', () => {
275
277
  }),
276
278
  };
277
279
 
278
- const patch = mapper.suggestionToPatch(suggestion, 'opp-123');
280
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-123');
281
+ expect(patches.length).to.equal(1);
282
+ const patch = patches[0];
279
283
 
280
284
  expect(patch).to.deep.include({
281
285
  op: 'replace',
@@ -302,7 +306,9 @@ describe('HeadingsMapper', () => {
302
306
  }),
303
307
  };
304
308
 
305
- const patch = mapper.suggestionToPatch(suggestion, 'opp-456');
309
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-456');
310
+ expect(patches.length).to.equal(1);
311
+ const patch = patches[0];
306
312
 
307
313
  expect(patch).to.deep.include({
308
314
  op: 'insertAfter',
@@ -330,7 +336,9 @@ describe('HeadingsMapper', () => {
330
336
  }),
331
337
  };
332
338
 
333
- const patch = mapper.suggestionToPatch(suggestion, 'opp-789');
339
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-789');
340
+ expect(patches.length).to.equal(1);
341
+ const patch = patches[0];
334
342
 
335
343
  expect(patch).to.deep.include({
336
344
  op: 'replace',
@@ -344,7 +352,7 @@ describe('HeadingsMapper', () => {
344
352
  expect(patch.tag).to.be.undefined;
345
353
  });
346
354
 
347
- it('should return null for heading-missing-h1 without transformRules', () => {
355
+ it('should return empty array for heading-missing-h1 without transformRules', () => {
348
356
  const suggestion = {
349
357
  getId: () => 'sugg-999',
350
358
  getData: () => ({
@@ -353,12 +361,11 @@ describe('HeadingsMapper', () => {
353
361
  }),
354
362
  };
355
363
 
356
- const patch = mapper.suggestionToPatch(suggestion, 'opp-999');
357
-
358
- expect(patch).to.be.null;
364
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-999');
365
+ expect(patches.length).to.equal(0);
359
366
  });
360
367
 
361
- it('should return null for heading-h1-length without selector in transformRules', () => {
368
+ it('should return empty array for heading-h1-length without selector in transformRules', () => {
362
369
  const suggestion = {
363
370
  getId: () => 'sugg-888',
364
371
  getData: () => ({
@@ -370,9 +377,8 @@ describe('HeadingsMapper', () => {
370
377
  }),
371
378
  };
372
379
 
373
- const patch = mapper.suggestionToPatch(suggestion, 'opp-888');
374
-
375
- expect(patch).to.be.null;
380
+ const patches = mapper.suggestionsToPatches('/path', [suggestion], 'opp-888');
381
+ expect(patches.length).to.equal(0);
376
382
  });
377
383
 
378
384
  it('should log warning for heading-missing-h1 with missing transformRules - validation path', () => {
@@ -393,9 +399,9 @@ describe('HeadingsMapper', () => {
393
399
  }),
394
400
  };
395
401
 
396
- const patch = warnMapper.suggestionToPatch(suggestion, 'opp-warn');
402
+ const patches = warnMapper.suggestionsToPatches('/path', [suggestion], 'opp-warn');
397
403
 
398
- expect(patch).to.be.null;
404
+ expect(patches.length).to.equal(0);
399
405
  expect(warnLogged).to.be.true;
400
406
  });
401
407
 
@@ -419,9 +425,9 @@ describe('HeadingsMapper', () => {
419
425
  }),
420
426
  };
421
427
 
422
- const patch = warnMapper.suggestionToPatch(suggestion, 'opp-defensive');
428
+ const patches = warnMapper.suggestionsToPatches('/path', [suggestion], 'opp-defensive');
423
429
 
424
- expect(patch).to.be.null;
430
+ expect(patches.length).to.equal(0);
425
431
  expect(warnMessage).to.include('cannot be deployed');
426
432
  });
427
433
  });
@@ -0,0 +1,432 @@
1
+ /*
2
+ * Copyright 2025 Adobe. All rights reserved.
3
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License. You may obtain a copy
5
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
6
+ *
7
+ * Unless required by applicable law or agreed to in writing, software distributed under
8
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9
+ * OF ANY KIND, either express or implied. See the License for the specific language
10
+ * governing permissions and limitations under the License.
11
+ */
12
+
13
+ /* eslint-env mocha */
14
+
15
+ import { expect } from 'chai';
16
+ import sinon from 'sinon';
17
+ import { fetchHtmlWithWarmup } from '../../src/utils/custom-html-utils.js';
18
+
19
+ describe('HTML Utils', () => {
20
+ describe('fetchHtmlWithWarmup', () => {
21
+ let fetchStub;
22
+ let log;
23
+
24
+ beforeEach(() => {
25
+ fetchStub = sinon.stub(global, 'fetch');
26
+ log = {
27
+ debug: sinon.stub(),
28
+ warn: sinon.stub(),
29
+ error: sinon.stub(),
30
+ info: sinon.stub(),
31
+ };
32
+ });
33
+
34
+ afterEach(() => {
35
+ sinon.restore();
36
+ });
37
+
38
+ it('should throw error when URL is missing', async () => {
39
+ try {
40
+ await fetchHtmlWithWarmup(
41
+ '',
42
+ 'host',
43
+ 'edge-url',
44
+ log,
45
+ false,
46
+ );
47
+ expect.fail('Should have thrown error');
48
+ } catch (error) {
49
+ expect(error.message).to.equal('URL is required for fetching HTML');
50
+ }
51
+ });
52
+
53
+ it('should throw error when forwardedHost is missing', async () => {
54
+ try {
55
+ await fetchHtmlWithWarmup(
56
+ 'https://example.com/page',
57
+ 'api-key',
58
+ '',
59
+ 'edge-url',
60
+ log,
61
+ false,
62
+ );
63
+ expect.fail('Should have thrown error');
64
+ } catch (error) {
65
+ expect(error.message).to.equal('Forwarded host is required for fetching HTML');
66
+ }
67
+ });
68
+
69
+ it('should throw error when tokowakaEdgeUrl is missing', async () => {
70
+ try {
71
+ await fetchHtmlWithWarmup(
72
+ 'https://example.com/page',
73
+ 'api-key',
74
+ 'host',
75
+ '',
76
+ log,
77
+ false,
78
+ );
79
+ expect.fail('Should have thrown error');
80
+ } catch (error) {
81
+ expect(error.message).to.equal('TOKOWAKA_EDGE_URL is not configured');
82
+ }
83
+ });
84
+
85
+ it('should throw error when apiKey is missing', async () => {
86
+ try {
87
+ await fetchHtmlWithWarmup(
88
+ 'https://example.com/page',
89
+ '',
90
+ 'host',
91
+ 'edge-url',
92
+ log,
93
+ false,
94
+ );
95
+ expect.fail('Should have thrown error');
96
+ } catch (error) {
97
+ expect(error.message).to.equal('Tokowaka API key is required for fetching HTML');
98
+ }
99
+ });
100
+
101
+ it('should successfully fetch HTML with all required parameters', async () => {
102
+ fetchStub.resolves({
103
+ ok: true,
104
+ status: 200,
105
+ statusText: 'OK',
106
+ headers: {
107
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
108
+ },
109
+ text: async () => '<html>Test HTML</html>',
110
+ });
111
+
112
+ const html = await fetchHtmlWithWarmup(
113
+ 'https://example.com/page',
114
+ 'api-key',
115
+ 'host',
116
+ 'https://edge.example.com',
117
+ log,
118
+ false,
119
+ { warmupDelayMs: 0 },
120
+ );
121
+
122
+ expect(html).to.equal('<html>Test HTML</html>');
123
+ expect(fetchStub.callCount).to.equal(2); // warmup + actual
124
+ });
125
+
126
+ it('should handle URL with existing query parameters when fetching optimized HTML', async () => {
127
+ fetchStub.resolves({
128
+ ok: true,
129
+ status: 200,
130
+ statusText: 'OK',
131
+ headers: {
132
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
133
+ },
134
+ text: async () => '<html>Optimized HTML</html>',
135
+ });
136
+
137
+ const html = await fetchHtmlWithWarmup(
138
+ 'https://example.com/page?param=value',
139
+ 'api-key',
140
+ 'host',
141
+ 'https://edge.example.com',
142
+ log,
143
+ true, // isOptimized
144
+ { warmupDelayMs: 0 },
145
+ );
146
+
147
+ expect(html).to.equal('<html>Optimized HTML</html>');
148
+ expect(fetchStub.callCount).to.equal(2); // warmup + actual
149
+
150
+ // Verify the URL includes & for the preview param (not ?)
151
+ const actualUrl = fetchStub.secondCall.args[0];
152
+ expect(actualUrl).to.include('param=value');
153
+ expect(actualUrl).to.include('&tokowakaPreview=true');
154
+ expect(actualUrl).to.not.include('?tokowakaPreview=true');
155
+ });
156
+
157
+ it('should throw error when HTTP response is not ok', async () => {
158
+ // Warmup succeeds
159
+ fetchStub.onCall(0).resolves({
160
+ ok: true,
161
+ status: 200,
162
+ statusText: 'OK',
163
+ headers: {
164
+ get: () => null,
165
+ },
166
+ text: async () => 'warmup',
167
+ });
168
+ // Actual call returns 404
169
+ fetchStub.onCall(1).resolves({
170
+ ok: false,
171
+ status: 404,
172
+ statusText: 'Not Found',
173
+ headers: {
174
+ get: () => null,
175
+ },
176
+ });
177
+
178
+ try {
179
+ await fetchHtmlWithWarmup(
180
+ 'https://example.com/page',
181
+ 'api-key',
182
+ 'host',
183
+ 'https://edge.example.com',
184
+ log,
185
+ false,
186
+ { warmupDelayMs: 0, maxRetries: 0 },
187
+ );
188
+ expect.fail('Should have thrown error');
189
+ } catch (error) {
190
+ expect(error.message).to.include('Failed to fetch original HTML');
191
+ expect(error.message).to.include('0 retries');
192
+ }
193
+ });
194
+
195
+ it('should retry and eventually throw error after max retries', async () => {
196
+ // Warmup succeeds
197
+ fetchStub.onCall(0).resolves({
198
+ ok: true,
199
+ status: 200,
200
+ statusText: 'OK',
201
+ text: async () => 'warmup',
202
+ });
203
+ // All actual calls fail
204
+ fetchStub.onCall(1).rejects(new Error('Network error'));
205
+ fetchStub.onCall(2).rejects(new Error('Network error'));
206
+ fetchStub.onCall(3).rejects(new Error('Network error'));
207
+
208
+ try {
209
+ await fetchHtmlWithWarmup(
210
+ 'https://example.com/page',
211
+ 'api-key',
212
+ 'host',
213
+ 'https://edge.example.com',
214
+ log,
215
+ false,
216
+ { warmupDelayMs: 0, maxRetries: 2, retryDelayMs: 0 },
217
+ );
218
+ expect.fail('Should have thrown error');
219
+ } catch (error) {
220
+ expect(error.message).to.include('Failed to fetch original HTML');
221
+ expect(error.message).to.include('Network error');
222
+ }
223
+
224
+ // Should have tried 3 times (initial + 2 retries) plus warmup
225
+ expect(fetchStub.callCount).to.equal(4);
226
+ });
227
+
228
+ it('should handle zero maxRetries value', async () => {
229
+ // Warmup succeeds
230
+ fetchStub.onCall(0).resolves({
231
+ ok: true,
232
+ status: 200,
233
+ statusText: 'OK',
234
+ text: async () => 'warmup',
235
+ });
236
+ // Actual call fails
237
+ fetchStub.onCall(1).rejects(new Error('Network error'));
238
+
239
+ try {
240
+ await fetchHtmlWithWarmup(
241
+ 'https://example.com/page',
242
+ 'api-key',
243
+ 'host',
244
+ 'https://edge.example.com',
245
+ log,
246
+ false,
247
+ { warmupDelayMs: 0, maxRetries: 0 },
248
+ );
249
+ expect.fail('Should have thrown error');
250
+ } catch (error) {
251
+ expect(error.message).to.include('Network error');
252
+ }
253
+
254
+ // Should have tried only once (no retries) plus warmup
255
+ expect(fetchStub.callCount).to.equal(2);
256
+ });
257
+
258
+ it('should handle negative maxRetries as edge case', async () => {
259
+ // Warmup succeeds
260
+ fetchStub.onCall(0).resolves({
261
+ ok: true,
262
+ status: 200,
263
+ statusText: 'OK',
264
+ headers: {
265
+ get: () => null,
266
+ },
267
+ text: async () => 'warmup',
268
+ });
269
+
270
+ try {
271
+ // With maxRetries: -1, the retry loop won't execute
272
+ // This tests the defensive 'throw lastError' fallback
273
+ await fetchHtmlWithWarmup(
274
+ 'https://example.com/page',
275
+ 'host',
276
+ 'https://edge.example.com',
277
+ log,
278
+ false,
279
+ { warmupDelayMs: 0, maxRetries: -1 },
280
+ );
281
+ expect.fail('Should have thrown error');
282
+ } catch (error) {
283
+ // Should throw the lastError from the loop
284
+ expect(error).to.exist;
285
+ }
286
+ });
287
+
288
+ it('should stop retrying when x-tokowaka-cache header is found', async () => {
289
+ // Warmup succeeds
290
+ fetchStub.onCall(0).resolves({
291
+ ok: true,
292
+ status: 200,
293
+ statusText: 'OK',
294
+ headers: {
295
+ get: () => null,
296
+ },
297
+ text: async () => 'warmup',
298
+ });
299
+ // First actual call - no cache header
300
+ fetchStub.onCall(1).resolves({
301
+ ok: true,
302
+ status: 200,
303
+ statusText: 'OK',
304
+ headers: {
305
+ get: () => null,
306
+ },
307
+ text: async () => '<html>No cache</html>',
308
+ });
309
+ // Second actual call - cache header found
310
+ fetchStub.onCall(2).resolves({
311
+ ok: true,
312
+ status: 200,
313
+ statusText: 'OK',
314
+ headers: {
315
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
316
+ },
317
+ text: async () => '<html>Cached HTML</html>',
318
+ });
319
+
320
+ const html = await fetchHtmlWithWarmup(
321
+ 'https://example.com/page',
322
+ 'api-key',
323
+ 'host',
324
+ 'https://edge.example.com',
325
+ log,
326
+ false,
327
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
328
+ );
329
+
330
+ expect(html).to.equal('<html>Cached HTML</html>');
331
+ // Should stop after finding cache header (warmup + 2 attempts)
332
+ expect(fetchStub.callCount).to.equal(3);
333
+ });
334
+
335
+ it('should throw error when cache header not found after max retries', async () => {
336
+ // Warmup succeeds
337
+ fetchStub.onCall(0).resolves({
338
+ ok: true,
339
+ status: 200,
340
+ statusText: 'OK',
341
+ headers: {
342
+ get: () => null,
343
+ },
344
+ text: async () => 'warmup',
345
+ });
346
+ // All actual calls succeed but no cache header
347
+ fetchStub.onCall(1).resolves({
348
+ ok: true,
349
+ status: 200,
350
+ statusText: 'OK',
351
+ headers: {
352
+ get: () => null,
353
+ },
354
+ text: async () => '<html>No cache 1</html>',
355
+ });
356
+ fetchStub.onCall(2).resolves({
357
+ ok: true,
358
+ status: 200,
359
+ statusText: 'OK',
360
+ headers: {
361
+ get: () => null,
362
+ },
363
+ text: async () => '<html>No cache 2</html>',
364
+ });
365
+ fetchStub.onCall(3).resolves({
366
+ ok: true,
367
+ status: 200,
368
+ statusText: 'OK',
369
+ headers: {
370
+ get: () => null,
371
+ },
372
+ text: async () => '<html>No cache 3</html>',
373
+ });
374
+
375
+ try {
376
+ await fetchHtmlWithWarmup(
377
+ 'https://example.com/page',
378
+ 'api-key',
379
+ 'host',
380
+ 'https://edge.example.com',
381
+ log,
382
+ false,
383
+ { warmupDelayMs: 0, maxRetries: 2, retryDelayMs: 0 },
384
+ );
385
+ expect.fail('Should have thrown error');
386
+ } catch (error) {
387
+ expect(error.message).to.include('Failed to fetch original HTML');
388
+ expect(error.message).to.include('Cache header (x-tokowaka-cache) not found after 2 retries');
389
+ }
390
+
391
+ // Should have tried 3 times (initial + 2 retries) plus warmup
392
+ expect(fetchStub.callCount).to.equal(4);
393
+ });
394
+
395
+ it('should return immediately on first attempt if cache header is present', async () => {
396
+ // Warmup succeeds
397
+ fetchStub.onCall(0).resolves({
398
+ ok: true,
399
+ status: 200,
400
+ statusText: 'OK',
401
+ headers: {
402
+ get: () => null,
403
+ },
404
+ text: async () => 'warmup',
405
+ });
406
+ // First actual call has cache header
407
+ fetchStub.onCall(1).resolves({
408
+ ok: true,
409
+ status: 200,
410
+ statusText: 'OK',
411
+ headers: {
412
+ get: (name) => (name === 'x-tokowaka-cache' ? 'HIT' : null),
413
+ },
414
+ text: async () => '<html>Cached HTML</html>',
415
+ });
416
+
417
+ const html = await fetchHtmlWithWarmup(
418
+ 'https://example.com/page',
419
+ 'api-key',
420
+ 'host',
421
+ 'https://edge.example.com',
422
+ log,
423
+ false,
424
+ { warmupDelayMs: 0, maxRetries: 3, retryDelayMs: 0 },
425
+ );
426
+
427
+ expect(html).to.equal('<html>Cached HTML</html>');
428
+ // Should not retry if cache header found on first attempt
429
+ expect(fetchStub.callCount).to.equal(2); // warmup + 1 actual
430
+ });
431
+ });
432
+ });