Skip to content

Commit 3ae7e4e

Browse files
Add unit tests with HTTP stubbing for UpdateCompany component (#965)
Replaces the TODO block with 6 proper unit tests using sinon stubs on Hubspot.prototype.call, as requested in PR #859 review. Tests cover: - Update by companyId with merge strategy (only updates empty fields) - Update by domain with search-first flow - Overwrite strategy (skips existing field check) - No fields to update with merge strategy - Error when domain not found - additionalProperties for custom/Clearbit fields Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 80307ed commit 3ae7e4e

File tree

1 file changed

+259
-13
lines changed

1 file changed

+259
-13
lines changed
Lines changed: 259 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,20 @@
11
'use strict';
22

33
const assert = require('assert');
4+
const sinon = require('sinon');
45
const path = require('path');
56

67
require('dotenv').config({ path: path.resolve(__dirname, '../../../../../.env') });
78

9+
const Hubspot = require('../../Hubspot');
810
const componentPath = '../../crm/UpdateCompany/UpdateCompany';
911
const component = require(componentPath);
1012
const { createMockContext } = require('../../../../../test/utils');
1113

1214
describe('HubSpot -> UpdateCompany', () => {
1315

1416
let context;
17+
let hubspotCallStub;
1518
const mockAccessToken = 'test-access-token';
1619

1720
beforeEach(() => {
@@ -20,9 +23,14 @@ describe('HubSpot -> UpdateCompany', () => {
2023
accessToken: mockAccessToken
2124
}
2225
});
26+
hubspotCallStub = sinon.stub(Hubspot.prototype, 'call');
2327
});
2428

25-
// Validation tests - these test important logic without requiring HTTP mocking
29+
afterEach(() => {
30+
sinon.restore();
31+
});
32+
33+
// Validation tests
2634
it('should throw error when neither companyId nor domain is provided', async () => {
2735
context.messages = {
2836
in: {
@@ -40,16 +48,254 @@ describe('HubSpot -> UpdateCompany', () => {
4048
}
4149
});
4250

43-
// TODO: Add HTTP mocking tests
44-
// The following tests require proper axios/HTTP mocking to work in CI environment:
45-
// - should update company by companyId with merge strategy
46-
// - should update company by domain (search first)
47-
// - should update company with overwrite strategy
48-
// - should handle no fields to update with merge strategy
49-
// - should throw error when domain not found
50-
// - should handle additionalProperties for custom fields (e.g. Clearbit fields)
51-
//
52-
// These components have been tested in staging environment with real HubSpot API.
53-
// The merge strategy logic (only updating empty fields) has been verified to work correctly.
54-
// HTTP mocking will be added after consulting AppMixer team on their preferred patterns.
51+
// HTTP mocking tests using sinon stubs on Hubspot.prototype.call
52+
it('should update company by companyId with merge strategy', async () => {
53+
context.messages = {
54+
in: {
55+
content: {
56+
companyId: '123',
57+
name: 'New Name',
58+
industry: 'Technology'
59+
}
60+
}
61+
};
62+
63+
// First call: GET existing company (merge strategy fetches current data)
64+
hubspotCallStub.withArgs('get', 'crm/v3/objects/companies/123').resolves({
65+
data: {
66+
id: '123',
67+
properties: {
68+
name: 'Existing Name', // non-empty, should NOT be overwritten
69+
industry: '' // empty, should be updated
70+
}
71+
}
72+
});
73+
74+
// Second call: PATCH with only the empty fields
75+
hubspotCallStub.withArgs('patch', 'crm/v3/objects/companies/123').resolves({
76+
data: {
77+
id: '123',
78+
properties: {
79+
name: 'Existing Name',
80+
industry: 'Technology'
81+
}
82+
}
83+
});
84+
85+
await component.receive(context);
86+
87+
// Verify PATCH was called with only the empty field (industry), not name
88+
const patchCall = hubspotCallStub.getCalls().find(c => c.args[0] === 'patch');
89+
assert(patchCall, 'PATCH should have been called');
90+
assert.strictEqual(patchCall.args[2].properties.industry, 'Technology');
91+
assert.strictEqual(patchCall.args[2].properties.name, undefined);
92+
93+
// Verify sendJson called with updated: true
94+
assert(context.sendJson.calledOnce, 'sendJson should be called once');
95+
const output = context.sendJson.getCall(0).args[0];
96+
assert.strictEqual(output.updated, true);
97+
});
98+
99+
it('should update company by domain (search first)', async () => {
100+
context.messages = {
101+
in: {
102+
content: {
103+
domain: 'example.com',
104+
name: 'New Name'
105+
}
106+
}
107+
};
108+
109+
// First call: POST search by domain
110+
hubspotCallStub.withArgs('post', 'crm/v3/objects/companies/search').resolves({
111+
data: {
112+
results: [{ id: '456' }]
113+
}
114+
});
115+
116+
// Second call: GET existing company for merge strategy
117+
hubspotCallStub.withArgs('get', 'crm/v3/objects/companies/456').resolves({
118+
data: {
119+
id: '456',
120+
properties: {
121+
domain: 'example.com',
122+
name: '' // empty, should be updated
123+
}
124+
}
125+
});
126+
127+
// Third call: PATCH with updated fields
128+
hubspotCallStub.withArgs('patch', 'crm/v3/objects/companies/456').resolves({
129+
data: {
130+
id: '456',
131+
properties: {
132+
domain: 'example.com',
133+
name: 'New Name'
134+
}
135+
}
136+
});
137+
138+
await component.receive(context);
139+
140+
// Verify search was called with correct domain filter
141+
const searchCall = hubspotCallStub.getCalls().find(c => c.args[0] === 'post');
142+
assert(searchCall, 'Search POST should have been called');
143+
const filters = searchCall.args[2].filterGroups[0].filters;
144+
assert.strictEqual(filters[0].propertyName, 'domain');
145+
assert.strictEqual(filters[0].value, 'example.com');
146+
147+
// Verify sendJson called with updated: true
148+
assert(context.sendJson.calledOnce, 'sendJson should be called once');
149+
const output = context.sendJson.getCall(0).args[0];
150+
assert.strictEqual(output.updated, true);
151+
});
152+
153+
it('should update company with overwrite strategy', async () => {
154+
context.messages = {
155+
in: {
156+
content: {
157+
companyId: '123',
158+
name: 'Overwritten Name',
159+
industry: 'Finance',
160+
updateStrategy: 'overwrite'
161+
}
162+
}
163+
};
164+
165+
// Overwrite strategy skips the GET call, goes straight to PATCH
166+
hubspotCallStub.withArgs('patch', 'crm/v3/objects/companies/123').resolves({
167+
data: {
168+
id: '123',
169+
properties: {
170+
name: 'Overwritten Name',
171+
industry: 'Finance'
172+
}
173+
}
174+
});
175+
176+
await component.receive(context);
177+
178+
// Verify no GET call was made (overwrite doesn't check existing fields)
179+
const getCalls = hubspotCallStub.getCalls().filter(c => c.args[0] === 'get');
180+
assert.strictEqual(getCalls.length, 0, 'GET should not be called for overwrite strategy');
181+
182+
// Verify PATCH includes all provided properties
183+
const patchCall = hubspotCallStub.getCalls().find(c => c.args[0] === 'patch');
184+
assert(patchCall, 'PATCH should have been called');
185+
assert.strictEqual(patchCall.args[2].properties.name, 'Overwritten Name');
186+
assert.strictEqual(patchCall.args[2].properties.industry, 'Finance');
187+
188+
// Verify sendJson called with updated: true
189+
assert(context.sendJson.calledOnce, 'sendJson should be called once');
190+
const output = context.sendJson.getCall(0).args[0];
191+
assert.strictEqual(output.updated, true);
192+
});
193+
194+
it('should handle no fields to update with merge strategy', async () => {
195+
context.messages = {
196+
in: {
197+
content: {
198+
companyId: '123',
199+
name: 'Already Set'
200+
}
201+
}
202+
};
203+
204+
const existingCompanyData = {
205+
id: '123',
206+
properties: {
207+
name: 'Already Set' // non-empty, matches input -- nothing to update
208+
}
209+
};
210+
211+
// First GET: merge strategy fetches existing company
212+
// Second GET: "no fields to update" path also fetches existing company
213+
hubspotCallStub.withArgs('get', 'crm/v3/objects/companies/123').resolves({
214+
data: existingCompanyData
215+
});
216+
217+
await component.receive(context);
218+
219+
// Verify no PATCH call was made
220+
const patchCalls = hubspotCallStub.getCalls().filter(c => c.args[0] === 'patch');
221+
assert.strictEqual(patchCalls.length, 0, 'PATCH should not be called when no fields to update');
222+
223+
// Verify sendJson called with updated: false
224+
assert(context.sendJson.calledOnce, 'sendJson should be called once');
225+
const output = context.sendJson.getCall(0).args[0];
226+
assert.strictEqual(output.updated, false);
227+
assert.strictEqual(output.message, 'No empty fields to update (merge strategy)');
228+
});
229+
230+
it('should throw error when domain not found', async () => {
231+
context.messages = {
232+
in: {
233+
content: {
234+
domain: 'nonexistent.com',
235+
name: 'Some Company'
236+
}
237+
}
238+
};
239+
240+
// Search returns empty results
241+
hubspotCallStub.withArgs('post', 'crm/v3/objects/companies/search').resolves({
242+
data: {
243+
results: []
244+
}
245+
});
246+
247+
try {
248+
await component.receive(context);
249+
assert.fail('Should have thrown an error');
250+
} catch (error) {
251+
assert.strictEqual(error.message, 'Company with domain "nonexistent.com" not found!');
252+
}
253+
});
254+
255+
it('should handle additionalProperties for custom fields (e.g. Clearbit fields)', async () => {
256+
context.messages = {
257+
in: {
258+
content: {
259+
companyId: '123',
260+
name: 'Test Corp',
261+
updateStrategy: 'overwrite',
262+
additionalProperties: {
263+
AND: [
264+
{ name: 'clearbit_industry', value: 'Technology' },
265+
{ name: 'clearbit_employee_count', value: '500' },
266+
{ name: 'pageviews_last_30_days', value: '42' }
267+
]
268+
}
269+
}
270+
}
271+
};
272+
273+
hubspotCallStub.withArgs('patch', 'crm/v3/objects/companies/123').resolves({
274+
data: {
275+
id: '123',
276+
properties: {
277+
name: 'Test Corp',
278+
clearbit_industry: 'Technology',
279+
clearbit_employee_count: '500',
280+
pageviews_last_30_days: '42'
281+
}
282+
}
283+
});
284+
285+
await component.receive(context);
286+
287+
// Verify PATCH payload includes both standard and additional properties
288+
const patchCall = hubspotCallStub.getCalls().find(c => c.args[0] === 'patch');
289+
assert(patchCall, 'PATCH should have been called');
290+
const patchedProps = patchCall.args[2].properties;
291+
assert.strictEqual(patchedProps.name, 'Test Corp');
292+
assert.strictEqual(patchedProps.clearbit_industry, 'Technology');
293+
assert.strictEqual(patchedProps.clearbit_employee_count, '500');
294+
assert.strictEqual(patchedProps.pageviews_last_30_days, '42');
295+
296+
// Verify sendJson called with updated: true
297+
assert(context.sendJson.calledOnce, 'sendJson should be called once');
298+
const output = context.sendJson.getCall(0).args[0];
299+
assert.strictEqual(output.updated, true);
300+
});
55301
});

0 commit comments

Comments
 (0)