@adobe/spacecat-shared-tokowaka-client 1.0.2 → 1.0.4

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.
@@ -33,30 +33,44 @@ export default class HeadingsMapper extends BaseOpportunityMapper {
33
33
  return this.prerenderRequired;
34
34
  }
35
35
 
36
- suggestionToPatch(suggestion, opportunityId) {
37
- const eligibility = this.canDeploy(suggestion);
38
- if (!eligibility.eligible) {
39
- this.log.warn(`Headings suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
40
- return null;
41
- }
36
+ /**
37
+ * Converts suggestions to Tokowaka patches
38
+ * @param {string} urlPath - URL path for the suggestions
39
+ * @param {Array} suggestions - Array of suggestion entities for the same URL
40
+ * @param {string} opportunityId - Opportunity ID
41
+ * @returns {Array} - Array of Tokowaka patch objects
42
+ */
43
+ suggestionsToPatches(urlPath, suggestions, opportunityId) {
44
+ const patches = [];
42
45
 
43
- const data = suggestion.getData();
44
- const { checkType, transformRules } = data;
45
-
46
- const patch = {
47
- ...this.createBasePatch(suggestion, opportunityId),
48
- op: transformRules.action,
49
- selector: transformRules.selector,
50
- value: data.recommendedAction,
51
- valueFormat: 'text',
52
- ...(data.currentValue !== null && { currValue: data.currentValue }),
53
- target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
54
- };
55
-
56
- if (checkType === 'heading-missing-h1' && transformRules.tag) {
57
- patch.tag = transformRules.tag;
58
- }
59
- return patch;
46
+ suggestions.forEach((suggestion) => {
47
+ const eligibility = this.canDeploy(suggestion);
48
+ if (!eligibility.eligible) {
49
+ this.log.warn(`Headings suggestion ${suggestion.getId()} cannot be deployed: ${eligibility.reason}`);
50
+ return;
51
+ }
52
+
53
+ const data = suggestion.getData();
54
+ const { checkType, transformRules } = data;
55
+
56
+ const patch = {
57
+ ...this.createBasePatch(suggestion, opportunityId),
58
+ op: transformRules.action,
59
+ selector: transformRules.selector,
60
+ value: data.recommendedAction,
61
+ valueFormat: 'text',
62
+ ...(data.currentValue !== null && { currValue: data.currentValue }),
63
+ target: TARGET_USER_AGENTS_CATEGORIES.AI_BOTS,
64
+ };
65
+
66
+ if (checkType === 'heading-missing-h1' && transformRules.tag) {
67
+ patch.tag = transformRules.tag;
68
+ }
69
+
70
+ patches.push(patch);
71
+ });
72
+
73
+ return patches;
60
74
  }
61
75
 
62
76
  /**
@@ -12,6 +12,7 @@
12
12
 
13
13
  import HeadingsMapper from './headings-mapper.js';
14
14
  import ContentSummarizationMapper from './content-summarization-mapper.js';
15
+ import FaqMapper from './faq-mapper.js';
15
16
 
16
17
  /**
17
18
  * Registry for opportunity mappers
@@ -32,6 +33,7 @@ export default class MapperRegistry {
32
33
  const defaultMappers = [
33
34
  HeadingsMapper,
34
35
  ContentSummarizationMapper,
36
+ FaqMapper,
35
37
  // more mappers here
36
38
  ];
37
39
 
@@ -0,0 +1,24 @@
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
+ import { toHast } from 'mdast-util-to-hast';
14
+ import { fromMarkdown } from 'mdast-util-from-markdown';
15
+
16
+ /**
17
+ * Converts markdown text to HAST (Hypertext Abstract Syntax Tree) format
18
+ * @param {string} markdown - Markdown text
19
+ * @returns {Object} - HAST object
20
+ */
21
+ export function markdownToHast(markdown) {
22
+ const mdast = fromMarkdown(markdown);
23
+ return toHast(mdast);
24
+ }
@@ -0,0 +1,66 @@
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
+ /**
14
+ * Generates a unique key for a patch based on its structure
15
+ * Individual patches (one suggestion per patch):
16
+ * → Key: opportunityId:suggestionId
17
+ * Patches with no suggestionId:
18
+ * → Key: opportunityId
19
+ */
20
+ function getPatchKey(patch) {
21
+ // Heading patch (no suggestionId): use special key
22
+ if (!patch.suggestionId) {
23
+ return `${patch.opportunityId}`;
24
+ }
25
+
26
+ // Individual patches include suggestionId in key
27
+ // This ensures each suggestion gets its own separate patch
28
+ return `${patch.opportunityId}:${patch.suggestionId}`;
29
+ }
30
+
31
+ /**
32
+ * Merges new patches into existing patches based on patch keys
33
+ * - If a patch with the same key exists, it's updated
34
+ * - If a patch with a new key is found, it's added
35
+ * @param {Array} existingPatches - Array of existing patches
36
+ * @param {Array} newPatches - Array of new patches to merge
37
+ * @returns {Object} - { patches: Array, updateCount: number, addCount: number }
38
+ */
39
+ export function mergePatches(existingPatches, newPatches) {
40
+ // Create a map of existing patches by their key
41
+ const patchMap = new Map();
42
+ existingPatches.forEach((patch, index) => {
43
+ const key = getPatchKey(patch);
44
+ patchMap.set(key, { patch, index });
45
+ });
46
+
47
+ // Process new patches
48
+ const mergedPatches = [...existingPatches];
49
+ let updateCount = 0;
50
+ let addCount = 0;
51
+
52
+ newPatches.forEach((newPatch) => {
53
+ const key = getPatchKey(newPatch);
54
+ const existing = patchMap.get(key);
55
+
56
+ if (existing) {
57
+ mergedPatches[existing.index] = newPatch;
58
+ updateCount += 1;
59
+ } else {
60
+ mergedPatches.push(newPatch);
61
+ addCount += 1;
62
+ }
63
+ });
64
+
65
+ return { patches: mergedPatches, updateCount, addCount };
66
+ }
@@ -0,0 +1,20 @@
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
+ /**
14
+ * Generates S3 path for Tokowaka configuration
15
+ * @param {string} apiKey - Tokowaka API key
16
+ * @returns {string} - S3 path
17
+ */
18
+ export function getTokowakaConfigS3Path(apiKey) {
19
+ return `opportunities/${apiKey}`;
20
+ }
@@ -0,0 +1,25 @@
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
+ import { isValidUrl } from '@adobe/spacecat-shared-utils';
14
+
15
+ /**
16
+ * Gets the effective base URL for a site, respecting overrideBaseURL if configured
17
+ * @param {Object} site - Site entity
18
+ * @returns {string} - Base URL to use
19
+ */
20
+ export function getEffectiveBaseURL(site) {
21
+ const overrideBaseURL = site.getConfig()?.getFetchConfig?.()?.overrideBaseURL;
22
+ return (overrideBaseURL && isValidUrl(overrideBaseURL))
23
+ ? overrideBaseURL
24
+ : site.getBaseURL();
25
+ }
@@ -0,0 +1,69 @@
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
+ /**
14
+ * Groups suggestions by URL pathname
15
+ * @param {Array} suggestions - Array of suggestion entities
16
+ * @param {string} baseURL - Base URL for pathname extraction
17
+ * @param {Object} log - Logger instance
18
+ * @returns {Object} - Object with URL paths as keys and arrays of suggestions as values
19
+ */
20
+ export function groupSuggestionsByUrlPath(suggestions, baseURL, log) {
21
+ return suggestions.reduce((acc, suggestion) => {
22
+ const data = suggestion.getData();
23
+ const url = data?.url;
24
+
25
+ if (!url) {
26
+ log.warn(`Suggestion ${suggestion.getId()} does not have a URL, skipping`);
27
+ return acc;
28
+ }
29
+
30
+ let urlPath;
31
+ try {
32
+ urlPath = new URL(url, baseURL).pathname;
33
+ } catch (e) {
34
+ log.warn(`Failed to extract pathname from URL for suggestion ${suggestion.getId()}: ${url}`);
35
+ return acc;
36
+ }
37
+
38
+ if (!acc[urlPath]) {
39
+ acc[urlPath] = [];
40
+ }
41
+ acc[urlPath].push(suggestion);
42
+ return acc;
43
+ }, {});
44
+ }
45
+
46
+ /**
47
+ * Filters suggestions into eligible and ineligible based on mapper's canDeploy method
48
+ * @param {Array} suggestions - Array of suggestion entities
49
+ * @param {Object} mapper - Mapper instance with canDeploy method
50
+ * @returns {Object} - { eligible: Array, ineligible: Array<{suggestion, reason}> }
51
+ */
52
+ export function filterEligibleSuggestions(suggestions, mapper) {
53
+ const eligible = [];
54
+ const ineligible = [];
55
+
56
+ suggestions.forEach((suggestion) => {
57
+ const eligibility = mapper.canDeploy(suggestion);
58
+ if (eligibility.eligible) {
59
+ eligible.push(suggestion);
60
+ } else {
61
+ ineligible.push({
62
+ suggestion,
63
+ reason: eligibility.reason || 'Suggestion cannot be deployed',
64
+ });
65
+ }
66
+ });
67
+
68
+ return { eligible, ineligible };
69
+ }
@@ -199,7 +199,7 @@ describe('TokowakaClient', () => {
199
199
 
200
200
  describe('generateConfig', () => {
201
201
  it('should generate config for headings opportunity', () => {
202
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
202
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
203
203
 
204
204
  expect(config).to.deep.include({
205
205
  siteId: 'site-123',
@@ -218,12 +218,105 @@ describe('TokowakaClient', () => {
218
218
  selector: 'h1',
219
219
  value: 'New Heading',
220
220
  opportunityId: 'opp-123',
221
- suggestionId: 'sugg-1',
222
221
  prerenderRequired: true,
223
222
  });
223
+ expect(patch.suggestionId).to.equal('sugg-1');
224
224
  expect(patch).to.have.property('lastUpdated');
225
225
  });
226
226
 
227
+ it('should generate config for FAQ opportunity', () => {
228
+ mockOpportunity = {
229
+ getId: () => 'opp-faq-123',
230
+ getType: () => 'faq',
231
+ };
232
+
233
+ mockSuggestions = [
234
+ {
235
+ getId: () => 'sugg-faq-1',
236
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
237
+ getData: () => ({
238
+ url: 'https://example.com/page1',
239
+ headingText: 'FAQs',
240
+ shouldOptimize: true,
241
+ item: {
242
+ question: 'Question 1?',
243
+ answer: 'Answer 1.',
244
+ },
245
+ transformRules: {
246
+ action: 'appendChild',
247
+ selector: 'main',
248
+ },
249
+ }),
250
+ },
251
+ {
252
+ getId: () => 'sugg-faq-2',
253
+ getUpdatedAt: () => '2025-01-15T11:00:00.000Z',
254
+ getData: () => ({
255
+ url: 'https://example.com/page1',
256
+ headingText: 'FAQs',
257
+ shouldOptimize: true,
258
+ item: {
259
+ question: 'Question 2?',
260
+ answer: 'Answer 2.',
261
+ },
262
+ transformRules: {
263
+ action: 'appendChild',
264
+ selector: 'main',
265
+ },
266
+ }),
267
+ },
268
+ ];
269
+
270
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
271
+
272
+ expect(config).to.deep.include({
273
+ siteId: 'site-123',
274
+ baseURL: 'https://example.com',
275
+ version: '1.0',
276
+ tokowakaForceFail: false,
277
+ });
278
+
279
+ expect(config.tokowakaOptimizations).to.have.property('/page1');
280
+ expect(config.tokowakaOptimizations['/page1'].prerender).to.be.true;
281
+ expect(config.tokowakaOptimizations['/page1'].patches).to.have.length(3); // heading + 2 FAQs
282
+
283
+ // First patch: heading (no suggestionId)
284
+ const headingPatch = config.tokowakaOptimizations['/page1'].patches[0];
285
+ expect(headingPatch).to.include({
286
+ op: 'appendChild',
287
+ selector: 'main',
288
+ opportunityId: 'opp-faq-123',
289
+ prerenderRequired: true,
290
+ });
291
+ expect(headingPatch.suggestionId).to.be.undefined;
292
+ expect(headingPatch).to.have.property('lastUpdated');
293
+ expect(headingPatch.value.tagName).to.equal('h2');
294
+
295
+ // Second patch: first FAQ
296
+ const firstFaqPatch = config.tokowakaOptimizations['/page1'].patches[1];
297
+ expect(firstFaqPatch).to.include({
298
+ op: 'appendChild',
299
+ selector: 'main',
300
+ opportunityId: 'opp-faq-123',
301
+ prerenderRequired: true,
302
+ });
303
+ expect(firstFaqPatch.suggestionId).to.equal('sugg-faq-1');
304
+ expect(firstFaqPatch).to.have.property('lastUpdated');
305
+ expect(firstFaqPatch.value.tagName).to.equal('div');
306
+
307
+ // Third patch: second FAQ
308
+ const secondFaqPatch = config.tokowakaOptimizations['/page1'].patches[2];
309
+ expect(secondFaqPatch).to.include({
310
+ op: 'appendChild',
311
+ selector: 'main',
312
+ opportunityId: 'opp-faq-123',
313
+ prerenderRequired: true,
314
+ });
315
+ expect(secondFaqPatch.suggestionId).to.equal('sugg-faq-2');
316
+ expect(secondFaqPatch).to.have.property('lastUpdated');
317
+ expect(secondFaqPatch.value.tagName).to.equal('div');
318
+ });
319
+
227
320
  it('should group suggestions by URL path', () => {
228
321
  mockSuggestions = [
229
322
  {
@@ -254,7 +347,7 @@ describe('TokowakaClient', () => {
254
347
  },
255
348
  ];
256
349
 
257
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
350
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
258
351
 
259
352
  expect(Object.keys(config.tokowakaOptimizations)).to.have.length(2);
260
353
  expect(config.tokowakaOptimizations).to.have.property('/page1');
@@ -289,7 +382,7 @@ describe('TokowakaClient', () => {
289
382
  },
290
383
  ];
291
384
 
292
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
385
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
293
386
 
294
387
  expect(Object.keys(config.tokowakaOptimizations)).to.have.length(1);
295
388
  expect(config.tokowakaOptimizations).to.have.property('/relative-path');
@@ -306,7 +399,7 @@ describe('TokowakaClient', () => {
306
399
  },
307
400
  ];
308
401
 
309
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
402
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
310
403
 
311
404
  expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
312
405
  expect(log.warn).to.have.been.calledWith(sinon.match(/does not have a URL/));
@@ -329,7 +422,7 @@ describe('TokowakaClient', () => {
329
422
  },
330
423
  ];
331
424
 
332
- const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions);
425
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
333
426
 
334
427
  expect(Object.keys(config.tokowakaOptimizations)).to.have.length(0);
335
428
  expect(log.warn).to.have.been.calledWith(sinon.match(/Failed to extract pathname from URL/));
@@ -363,10 +456,23 @@ describe('TokowakaClient', () => {
363
456
  },
364
457
  ];
365
458
 
366
- expect(() => client.generateConfig(mockSite, mockOpportunity, mockSuggestions))
459
+ expect(() => client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null))
367
460
  .to.throw(/No mapper found for opportunity type: unsupported-type/)
368
461
  .with.property('status', 501);
369
462
  });
463
+
464
+ it('should generate config without allOpportunitySuggestions', () => {
465
+ const config = client.generateConfig(mockSite, mockOpportunity, mockSuggestions, null);
466
+
467
+ expect(config).to.deep.include({
468
+ siteId: 'site-123',
469
+ baseURL: 'https://example.com',
470
+ version: '1.0',
471
+ tokowakaForceFail: false,
472
+ });
473
+
474
+ expect(config.tokowakaOptimizations).to.have.property('/page1');
475
+ });
370
476
  });
371
477
 
372
478
  describe('uploadConfig', () => {
@@ -41,9 +41,9 @@ describe('BaseOpportunityMapper', () => {
41
41
  .to.throw('requiresPrerender() must be implemented by subclass');
42
42
  });
43
43
 
44
- it('suggestionToPatch should throw error', () => {
45
- expect(() => mapper.suggestionToPatch({}, 'opp-123'))
46
- .to.throw('suggestionToPatch() must be implemented by subclass');
44
+ it('suggestionsToPatches should throw error', () => {
45
+ expect(() => mapper.suggestionsToPatches('/path', [], 'opp-123', null))
46
+ .to.throw('suggestionsToPatches() must be implemented by subclass');
47
47
  });
48
48
 
49
49
  it('canDeploy should throw error if not implemented', () => {
@@ -60,14 +60,15 @@ describe('BaseOpportunityMapper', () => {
60
60
 
61
61
  requiresPrerender() { return true; }
62
62
 
63
- suggestionToPatch() { return {}; }
63
+ suggestionsToPatches() { return []; }
64
64
 
65
65
  canDeploy() { return { eligible: true }; }
66
66
  }
67
67
 
68
- const testMapper = new TestMapper();
68
+ const testMapper = new TestMapper(log);
69
69
  const suggestion = {
70
70
  getId: () => 'test-123',
71
+ getData: () => ({}),
71
72
  getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
72
73
  };
73
74
 
@@ -85,14 +86,15 @@ describe('BaseOpportunityMapper', () => {
85
86
 
86
87
  requiresPrerender() { return true; }
87
88
 
88
- suggestionToPatch() { return {}; }
89
+ suggestionsToPatches() { return []; }
89
90
 
90
91
  canDeploy() { return { eligible: true }; }
91
92
  }
92
93
 
93
- const testMapper = new TestMapper();
94
+ const testMapper = new TestMapper(log);
94
95
  const suggestion = {
95
96
  getId: () => 'test-no-date',
97
+ getData: () => ({}),
96
98
  getUpdatedAt: () => null, // Returns null
97
99
  };
98
100
 
@@ -106,5 +108,103 @@ describe('BaseOpportunityMapper', () => {
106
108
  expect(patch.lastUpdated).to.be.at.most(afterTime);
107
109
  expect(patch.prerenderRequired).to.be.true;
108
110
  });
111
+
112
+ it('should prioritize scrapedAt from getData()', () => {
113
+ class TestMapper extends BaseOpportunityMapper {
114
+ getOpportunityType() { return 'test'; }
115
+
116
+ requiresPrerender() { return true; }
117
+
118
+ suggestionsToPatches() { return []; }
119
+
120
+ canDeploy() { return { eligible: true }; }
121
+ }
122
+
123
+ const testMapper = new TestMapper(log);
124
+ const scrapedTime = '2025-01-20T15:30:00.000Z';
125
+ const suggestion = {
126
+ getId: () => 'test-scraped',
127
+ getData: () => ({ scrapedAt: scrapedTime }),
128
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
129
+ };
130
+
131
+ const patch = testMapper.createBasePatch(suggestion, 'opp-scraped');
132
+
133
+ expect(patch.lastUpdated).to.equal(new Date(scrapedTime).getTime());
134
+ });
135
+
136
+ it('should use transformRules.scrapedAt when scrapedAt is not available', () => {
137
+ class TestMapper extends BaseOpportunityMapper {
138
+ getOpportunityType() { return 'test'; }
139
+
140
+ requiresPrerender() { return true; }
141
+
142
+ suggestionsToPatches() { return []; }
143
+
144
+ canDeploy() { return { eligible: true }; }
145
+ }
146
+
147
+ const testMapper = new TestMapper(log);
148
+ const transformScrapedTime = '2025-01-18T12:00:00.000Z';
149
+ const suggestion = {
150
+ getId: () => 'test-transform',
151
+ getData: () => ({ transformRules: { scrapedAt: transformScrapedTime } }),
152
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
153
+ };
154
+
155
+ const patch = testMapper.createBasePatch(suggestion, 'opp-transform');
156
+
157
+ expect(patch.lastUpdated).to.equal(new Date(transformScrapedTime).getTime());
158
+ });
159
+
160
+ it('should handle invalid date strings by using Date.now()', () => {
161
+ class TestMapper extends BaseOpportunityMapper {
162
+ getOpportunityType() { return 'test'; }
163
+
164
+ requiresPrerender() { return true; }
165
+
166
+ suggestionsToPatches() { return []; }
167
+
168
+ canDeploy() { return { eligible: true }; }
169
+ }
170
+
171
+ const testMapper = new TestMapper(log);
172
+ const suggestion = {
173
+ getId: () => 'test-invalid',
174
+ getData: () => ({}),
175
+ getUpdatedAt: () => 'invalid-date-string',
176
+ };
177
+
178
+ const beforeTime = Date.now();
179
+ const patch = testMapper.createBasePatch(suggestion, 'opp-invalid');
180
+ const afterTime = Date.now();
181
+
182
+ // Should fallback to Date.now() for invalid dates
183
+ expect(patch.lastUpdated).to.be.at.least(beforeTime);
184
+ expect(patch.lastUpdated).to.be.at.most(afterTime);
185
+ });
186
+
187
+ it('should handle missing getData() gracefully', () => {
188
+ class TestMapper extends BaseOpportunityMapper {
189
+ getOpportunityType() { return 'test'; }
190
+
191
+ requiresPrerender() { return true; }
192
+
193
+ suggestionsToPatches() { return []; }
194
+
195
+ canDeploy() { return { eligible: true }; }
196
+ }
197
+
198
+ const testMapper = new TestMapper(log);
199
+ const suggestion = {
200
+ getId: () => 'test-no-data',
201
+ getData: () => null,
202
+ getUpdatedAt: () => '2025-01-15T10:00:00.000Z',
203
+ };
204
+
205
+ const patch = testMapper.createBasePatch(suggestion, 'opp-no-data');
206
+
207
+ expect(patch.lastUpdated).to.equal(new Date('2025-01-15T10:00:00.000Z').getTime());
208
+ });
109
209
  });
110
210
  });