@hubspot/ui-extensions 0.9.3 → 0.9.5

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.
@@ -0,0 +1,442 @@
1
+ // Set up the mock before importing the module
2
+ const mockFetchAssociations = jest.fn();
3
+ const mockSelf = {
4
+ fetchAssociations: mockFetchAssociations,
5
+ };
6
+ // Mock the global self object
7
+ Object.defineProperty(global, 'self', {
8
+ value: mockSelf,
9
+ writable: true,
10
+ });
11
+ import { fetchAssociations, pageToOffset, offsetToPage, calculatePaginationFlags, } from '../../../experimental/crm/fetchAssociations';
12
+ describe('fetchAssociations', () => {
13
+ // Helper functions
14
+ const createMockResponse = (data, overrides = {}) => ({
15
+ ok: true,
16
+ json: jest.fn().mockResolvedValue({ data, cleanup: jest.fn() }),
17
+ ...overrides,
18
+ });
19
+ const createErrorResponse = (statusText) => ({
20
+ ok: false,
21
+ statusText,
22
+ json: jest.fn().mockResolvedValue({}),
23
+ });
24
+ const expectError = async (request, expectedMessage) => {
25
+ await expect(fetchAssociations(request)).rejects.toThrow(expectedMessage);
26
+ };
27
+ const createBasicRequest = (overrides = {}) => ({
28
+ toObjectType: '0-1',
29
+ properties: ['firstname', 'lastname'],
30
+ ...overrides,
31
+ });
32
+ beforeEach(() => {
33
+ jest.clearAllMocks();
34
+ // Set up dynamic mock that responds to request parameters
35
+ mockFetchAssociations.mockImplementation((request) => {
36
+ const { pageLength = 100, offset = 0, properties = [] } = request;
37
+ // Generate mock properties based on the request
38
+ const mockProperties = {};
39
+ properties.forEach((prop) => {
40
+ mockProperties[prop] = `Mock ${prop} (page ${Math.floor(offset / pageLength) + 1})`;
41
+ });
42
+ const mockResults = [
43
+ {
44
+ toObjectId: 1000 + offset,
45
+ associationTypes: [
46
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
47
+ ],
48
+ properties: mockProperties,
49
+ },
50
+ {
51
+ toObjectId: 1001 + offset,
52
+ associationTypes: [
53
+ {
54
+ category: 'USER_DEFINED',
55
+ typeId: 100,
56
+ label: 'Custom Association',
57
+ },
58
+ ],
59
+ properties: mockProperties,
60
+ },
61
+ ];
62
+ const mockData = {
63
+ results: mockResults,
64
+ hasMore: offset < 200,
65
+ nextOffset: offset + pageLength,
66
+ };
67
+ return Promise.resolve(createMockResponse(mockData));
68
+ });
69
+ });
70
+ describe('basic functionality', () => {
71
+ it('should return mock associations with default parameters', async () => {
72
+ const request = createBasicRequest();
73
+ const result = await fetchAssociations(request);
74
+ expect(result).toHaveProperty('data');
75
+ expect(result).toHaveProperty('cleanup');
76
+ expect(typeof result.cleanup).toBe('function');
77
+ const { data } = result;
78
+ expect(data.results).toHaveLength(2);
79
+ expect(data.hasMore).toBe(true);
80
+ expect(data.nextOffset).toBe(100);
81
+ // Check first association
82
+ expect(data.results[0]).toEqual({
83
+ toObjectId: 1000,
84
+ associationTypes: [
85
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
86
+ ],
87
+ properties: {
88
+ firstname: 'Mock firstname (page 1)',
89
+ lastname: 'Mock lastname (page 1)',
90
+ },
91
+ });
92
+ // Check second association
93
+ expect(data.results[1]).toEqual({
94
+ toObjectId: 1001,
95
+ associationTypes: [
96
+ {
97
+ category: 'USER_DEFINED',
98
+ typeId: 100,
99
+ label: 'Custom Association',
100
+ },
101
+ ],
102
+ properties: {
103
+ firstname: 'Mock firstname (page 1)',
104
+ lastname: 'Mock lastname (page 1)',
105
+ },
106
+ });
107
+ });
108
+ it('should return a cleanup function', async () => {
109
+ const request = createBasicRequest({ properties: ['firstname'] });
110
+ const result = await fetchAssociations(request);
111
+ expect(typeof result.cleanup).toBe('function');
112
+ // Should not throw when called
113
+ expect(() => result.cleanup()).not.toThrow();
114
+ });
115
+ it('should return a Promise', () => {
116
+ const request = createBasicRequest({ properties: ['firstname'] });
117
+ const result = fetchAssociations(request);
118
+ expect(result).toBeInstanceOf(Promise);
119
+ });
120
+ });
121
+ describe('pagination handling', () => {
122
+ it('should handle custom page length and offset', async () => {
123
+ const request = createBasicRequest({
124
+ properties: ['email'],
125
+ pageLength: 25,
126
+ offset: 50,
127
+ });
128
+ const result = await fetchAssociations(request);
129
+ const { data } = result;
130
+ expect(data.results).toHaveLength(2);
131
+ expect(data.nextOffset).toBe(75); // 50 + 25
132
+ expect(data.hasMore).toBe(true);
133
+ // Check that offset is reflected in object IDs
134
+ expect(data.results[0].toObjectId).toBe(1050); // 1000 + 50
135
+ expect(data.results[1].toObjectId).toBe(1051); // 1001 + 50
136
+ // Check that page number is reflected in properties
137
+ expect(data.results[0].properties.email).toBe('Mock email (page 3)'); // Math.floor(50 / 25) + 1 = 3
138
+ });
139
+ it('should calculate hasMore correctly based on offset', async () => {
140
+ // Test when there are more results
141
+ const request1 = createBasicRequest({
142
+ properties: ['firstname'],
143
+ offset: 100,
144
+ });
145
+ const result1 = await fetchAssociations(request1);
146
+ expect(result1.data.hasMore).toBe(true);
147
+ // Test when there are no more results
148
+ const request2 = createBasicRequest({
149
+ properties: ['firstname'],
150
+ offset: 200,
151
+ });
152
+ const result2 = await fetchAssociations(request2);
153
+ expect(result2.data.hasMore).toBe(false);
154
+ // Test edge case at the boundary
155
+ const request3 = createBasicRequest({
156
+ properties: ['firstname'],
157
+ offset: 199,
158
+ });
159
+ const result3 = await fetchAssociations(request3);
160
+ expect(result3.data.hasMore).toBe(true);
161
+ });
162
+ it('should generate different page numbers for different offsets', async () => {
163
+ const request1 = createBasicRequest({
164
+ properties: ['firstname'],
165
+ pageLength: 10,
166
+ offset: 0,
167
+ });
168
+ const result1 = await fetchAssociations(request1);
169
+ expect(result1.data.results[0].properties.firstname).toBe('Mock firstname (page 1)');
170
+ const request2 = createBasicRequest({
171
+ properties: ['firstname'],
172
+ pageLength: 10,
173
+ offset: 20,
174
+ });
175
+ const result2 = await fetchAssociations(request2);
176
+ expect(result2.data.results[0].properties.firstname).toBe('Mock firstname (page 3)'); // Math.floor(20 / 10) + 1 = 3
177
+ });
178
+ });
179
+ describe('properties handling', () => {
180
+ it('should handle empty properties array', async () => {
181
+ const request = createBasicRequest({ properties: [] });
182
+ const result = await fetchAssociations(request);
183
+ const { data } = result;
184
+ expect(data.results).toHaveLength(2);
185
+ expect(data.results[0].properties).toEqual({});
186
+ expect(data.results[1].properties).toEqual({});
187
+ });
188
+ it('should handle missing properties in request', async () => {
189
+ const request = createBasicRequest();
190
+ // Remove properties to test missing case
191
+ delete request.properties;
192
+ const result = await fetchAssociations(request);
193
+ const { data } = result;
194
+ expect(data.results).toHaveLength(2);
195
+ expect(data.results[0].properties).toEqual({});
196
+ expect(data.results[1].properties).toEqual({});
197
+ });
198
+ it('should handle multiple properties correctly', async () => {
199
+ const request = createBasicRequest({
200
+ properties: ['firstname', 'lastname', 'email', 'phone'],
201
+ });
202
+ const result = await fetchAssociations(request);
203
+ const { data } = result;
204
+ expect(data.results[0].properties).toEqual({
205
+ firstname: 'Mock firstname (page 1)',
206
+ lastname: 'Mock lastname (page 1)',
207
+ email: 'Mock email (page 1)',
208
+ phone: 'Mock phone (page 1)',
209
+ });
210
+ });
211
+ it('should handle different object types', async () => {
212
+ const request = createBasicRequest({
213
+ toObjectType: '0-2',
214
+ properties: ['name'],
215
+ });
216
+ const result = await fetchAssociations(request);
217
+ const { data } = result;
218
+ expect(data.results).toHaveLength(2);
219
+ expect(data.results[0].properties.name).toBe('Mock name (page 1)');
220
+ });
221
+ });
222
+ describe('error handling', () => {
223
+ it('should throw an error when response is not OK', async () => {
224
+ mockFetchAssociations.mockResolvedValueOnce(createErrorResponse('Not Found'));
225
+ const request = createBasicRequest({ properties: ['firstname'] });
226
+ await expectError(request, 'Failed to fetch associations: Not Found');
227
+ });
228
+ it('should throw an error when fetch fails', async () => {
229
+ mockFetchAssociations.mockRejectedValueOnce(new Error('Network error'));
230
+ const request = createBasicRequest({ properties: ['firstname'] });
231
+ await expectError(request, 'Network error');
232
+ });
233
+ it('should handle unknown errors', async () => {
234
+ mockFetchAssociations.mockRejectedValueOnce('Unknown error');
235
+ const request = createBasicRequest({ properties: ['firstname'] });
236
+ await expectError(request, 'Failed to fetch associations: Unknown error');
237
+ });
238
+ });
239
+ describe('response validation', () => {
240
+ it('should throw error for invalid response format - missing results', async () => {
241
+ const invalidData = { hasMore: true, nextOffset: 0 }; // missing results
242
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
243
+ const request = createBasicRequest({ properties: ['firstname'] });
244
+ await expectError(request, 'Invalid response format');
245
+ });
246
+ it('should throw error for invalid response format - null data', async () => {
247
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(null));
248
+ const request = createBasicRequest({ properties: ['firstname'] });
249
+ await expectError(request, 'Invalid response format');
250
+ });
251
+ it('should throw error for invalid association result structure', async () => {
252
+ const invalidData = {
253
+ results: [{ invalidStructure: true }],
254
+ hasMore: false,
255
+ nextOffset: 0,
256
+ };
257
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
258
+ const request = createBasicRequest({ properties: ['firstname'] });
259
+ await expectError(request, 'Invalid response format');
260
+ });
261
+ it('should throw error for invalid association types array', async () => {
262
+ const invalidData = {
263
+ results: [
264
+ {
265
+ toObjectId: 1000,
266
+ associationTypes: 'not an array',
267
+ properties: {},
268
+ },
269
+ ],
270
+ hasMore: false,
271
+ nextOffset: 0,
272
+ };
273
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
274
+ const request = createBasicRequest({ properties: ['firstname'] });
275
+ await expectError(request, 'Invalid response format');
276
+ });
277
+ it('should throw error for missing hasMore field', async () => {
278
+ const invalidData = {
279
+ results: [],
280
+ nextOffset: 0, // missing hasMore
281
+ };
282
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(invalidData));
283
+ const request = createBasicRequest({ properties: ['firstname'] });
284
+ await expectError(request, 'Invalid response format');
285
+ });
286
+ });
287
+ describe('API integration', () => {
288
+ it('should call self.fetchAssociations with correct parameters', async () => {
289
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
290
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
291
+ const request = createBasicRequest({
292
+ properties: ['firstname'],
293
+ pageLength: 50,
294
+ });
295
+ await fetchAssociations(request);
296
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, undefined);
297
+ });
298
+ it('should return cleanup function from API response', async () => {
299
+ const mockCleanup = jest.fn();
300
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
301
+ const mockApiResponse = { data: emptyData, cleanup: mockCleanup };
302
+ mockFetchAssociations.mockResolvedValueOnce({
303
+ ok: true,
304
+ json: jest.fn().mockResolvedValue(mockApiResponse),
305
+ });
306
+ const result = await fetchAssociations(createBasicRequest());
307
+ expect(result.cleanup).toBe(mockCleanup);
308
+ result.cleanup();
309
+ expect(mockCleanup).toHaveBeenCalledTimes(1);
310
+ });
311
+ it('should provide default cleanup function when none provided', async () => {
312
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
313
+ const mockApiResponse = { data: emptyData }; // no cleanup function provided
314
+ mockFetchAssociations.mockResolvedValueOnce({
315
+ ok: true,
316
+ json: jest.fn().mockResolvedValue(mockApiResponse),
317
+ });
318
+ const result = await fetchAssociations(createBasicRequest());
319
+ expect(typeof result.cleanup).toBe('function');
320
+ expect(() => result.cleanup()).not.toThrow();
321
+ });
322
+ it('should successfully fetch associations with valid response', async () => {
323
+ const validData = {
324
+ results: [
325
+ {
326
+ toObjectId: 2000,
327
+ associationTypes: [
328
+ { category: 'HUBSPOT_DEFINED', typeId: 1, label: 'Primary' },
329
+ ],
330
+ properties: {
331
+ firstname: 'John',
332
+ lastname: 'Doe',
333
+ },
334
+ },
335
+ ],
336
+ hasMore: true,
337
+ nextOffset: 100,
338
+ };
339
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(validData));
340
+ const request = createBasicRequest();
341
+ const result = await fetchAssociations(request);
342
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, undefined);
343
+ expect(result.data).toEqual(validData);
344
+ expect(typeof result.cleanup).toBe('function');
345
+ });
346
+ });
347
+ describe('options handling', () => {
348
+ it('should pass formatting options to the underlying fetch function', async () => {
349
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
350
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
351
+ const options = {
352
+ propertiesToFormat: ['firstname'],
353
+ formattingOptions: {
354
+ date: { format: 'MM/dd/yyyy' },
355
+ currency: { addSymbol: true },
356
+ },
357
+ };
358
+ const request = createBasicRequest();
359
+ await fetchAssociations(request, options);
360
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
361
+ });
362
+ it('should handle propertiesToFormat set to "all"', async () => {
363
+ const emptyData = { results: [], hasMore: false, nextOffset: 0 };
364
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse(emptyData));
365
+ const options = {
366
+ propertiesToFormat: 'all',
367
+ formattingOptions: {
368
+ dateTime: { relative: true },
369
+ },
370
+ };
371
+ const request = createBasicRequest();
372
+ await fetchAssociations(request, options);
373
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
374
+ });
375
+ it('should preserve error handling with formatting options', async () => {
376
+ mockFetchAssociations.mockRejectedValueOnce(new Error('Network error'));
377
+ const options = {
378
+ propertiesToFormat: ['firstname'],
379
+ formattingOptions: {
380
+ date: { format: 'MM/dd/yyyy' },
381
+ },
382
+ };
383
+ const request = createBasicRequest({ properties: ['firstname'] });
384
+ await expect(fetchAssociations(request, options)).rejects.toThrow('Network error');
385
+ expect(mockFetchAssociations).toHaveBeenCalledWith(request, options);
386
+ });
387
+ it('should preserve response validation with formatting options', async () => {
388
+ mockFetchAssociations.mockResolvedValueOnce(createMockResponse('Invalid response')); // data should be an object
389
+ const options = {
390
+ propertiesToFormat: 'all',
391
+ };
392
+ const request = createBasicRequest({ properties: ['firstname'] });
393
+ await expect(fetchAssociations(request, options)).rejects.toThrow('Invalid response format');
394
+ });
395
+ });
396
+ describe('pagination utilities', () => {
397
+ describe('pageToOffset', () => {
398
+ it('should convert page numbers to offsets', () => {
399
+ expect(pageToOffset(1, 10)).toBe(0);
400
+ expect(pageToOffset(3, 10)).toBe(20);
401
+ expect(pageToOffset(2, 25)).toBe(25);
402
+ });
403
+ it('should always return 0 for page 1', () => {
404
+ expect(pageToOffset(1, 10)).toBe(0);
405
+ expect(pageToOffset(1, 50)).toBe(0);
406
+ });
407
+ });
408
+ describe('offsetToPage', () => {
409
+ it('should convert offsets to page numbers', () => {
410
+ expect(offsetToPage(0, 10)).toBe(1);
411
+ expect(offsetToPage(20, 10)).toBe(3);
412
+ expect(offsetToPage(15, 10)).toBe(2); // partial page
413
+ });
414
+ it('should always return 1 for offset 0', () => {
415
+ expect(offsetToPage(0, 10)).toBe(1);
416
+ expect(offsetToPage(0, 25)).toBe(1);
417
+ });
418
+ });
419
+ describe('calculatePaginationFlags', () => {
420
+ it('should calculate pagination flags correctly', () => {
421
+ // Page 1 scenarios
422
+ expect(calculatePaginationFlags(1, true)).toEqual({
423
+ hasNextPage: true,
424
+ hasPreviousPage: false,
425
+ });
426
+ expect(calculatePaginationFlags(1, false)).toEqual({
427
+ hasNextPage: false,
428
+ hasPreviousPage: false,
429
+ });
430
+ // Page 2+ scenarios
431
+ expect(calculatePaginationFlags(2, true)).toEqual({
432
+ hasNextPage: true,
433
+ hasPreviousPage: true,
434
+ });
435
+ expect(calculatePaginationFlags(2, false)).toEqual({
436
+ hasNextPage: false,
437
+ hasPreviousPage: true,
438
+ });
439
+ });
440
+ });
441
+ });
442
+ });
@@ -70,6 +70,7 @@ describe('fetchCrmProperties', () => {
70
70
  const mockResponse = {
71
71
  ok: false,
72
72
  statusText: 'Not Found',
73
+ json: jest.fn().mockResolvedValue({}),
73
74
  };
74
75
  mockFetchCrmProperties.mockResolvedValue(mockResponse);
75
76
  const propertyNames = ['firstname'];