Skip to content

Commit 654a865

Browse files
refactor: Salesforce getContacts (calcom#24235)
* lint fixes * Create getContactOrLeadFromEmail method * Use getContactOrLeadFromEmail in getContacts * Move normal getContact query * fix: correct return type in getContactOrLeadFromEmail and add search mock - Fix getContactOrLeadFromEmail to return single ContactRecord instead of array - Add conn.search() implementation to salesforceMock for integration tests - Ensure id field is always string with proper fallbacks for AccountId and Id - Fix Owner.Email to use undefined instead of null in mock Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * test: add comprehensive tests for getContactOrLeadFromEmail method - Add 6 new unit tests covering all key scenarios - Test contact lookup, lead fallback, and preference logic - Test both roundRobinSkipFallbackToLeadOwner and createEventOnLeadCheckForContact code paths - Add search mock support to mockConnection in beforeEach - All 34 tests passing, type-check clean Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> * Implement change to error from calcom#24090 * Address feedback * Type fix * test: update SOQL query expectations to include Account.OwnerId Co-Authored-By: joe@cal.com <j.auyeung419@gmail.com> --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent 409a027 commit 654a865

3 files changed

Lines changed: 368 additions & 79 deletions

File tree

packages/app-store/salesforce/lib/CrmService.test.ts

Lines changed: 227 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ const leadQueryResponse = {
5757
],
5858
};
5959

60-
const ownerQueryResponse = {
60+
const _ownerQueryResponse = {
6161
records: [
6262
{
6363
Id: "owner001",
@@ -88,14 +88,19 @@ vi.mock("./graphql/SalesforceGraphQLClient", () => ({
8888

8989
describe("SalesforceCRMService", () => {
9090
let service: SalesforceCRMService;
91-
let mockConnection: { query: any; sobject: any };
91+
let mockConnection: {
92+
query: ReturnType<typeof vi.fn>;
93+
sobject: ReturnType<typeof vi.fn>;
94+
search: ReturnType<typeof vi.fn>;
95+
};
9296

9397
setupAndTeardown();
9498

9599
beforeEach(() => {
96100
mockConnection = {
97101
query: vi.fn(),
98102
sobject: vi.fn(),
103+
search: vi.fn(),
99104
};
100105

101106
const mockCredential: CredentialPayload = {
@@ -334,7 +339,7 @@ describe("SalesforceCRMService", () => {
334339

335340
expect(querySpy).toHaveBeenNthCalledWith(
336341
1,
337-
"SELECT Id, Email, OwnerId, AccountId, Account.Owner.Email, Account.Website FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
342+
"SELECT Id, Email, OwnerId, AccountId, Account.OwnerId, Account.Owner.Email, Account.Website FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
338343
);
339344
});
340345
});
@@ -424,7 +429,7 @@ describe("SalesforceCRMService", () => {
424429

425430
expect(querySpy).toHaveBeenNthCalledWith(
426431
1,
427-
"SELECT Id, Email, OwnerId, AccountId, Account.Owner.Email, Account.Website FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
432+
"SELECT Id, Email, OwnerId, AccountId, Account.OwnerId, Account.Owner.Email, Account.Website FROM Contact WHERE Email = 'test@example.com' AND AccountId != null"
428433
);
429434
});
430435
});
@@ -522,6 +527,224 @@ describe("SalesforceCRMService", () => {
522527
});
523528
});
524529

530+
describe("getContactOrLeadFromEmail via getContacts", () => {
531+
describe("with roundRobinSkipFallbackToLeadOwner enabled", () => {
532+
it("should return contact when contact is found", async () => {
533+
mockAppOptions({
534+
createEventOn: SalesforceRecordEnum.CONTACT,
535+
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.CONTACT,
536+
roundRobinSkipFallbackToLeadOwner: true,
537+
});
538+
539+
const searchSpy = vi.spyOn(mockConnection, "search");
540+
searchSpy.mockResolvedValueOnce({
541+
searchRecords: [
542+
{
543+
Id: "contact001",
544+
Email: "test@example.com",
545+
OwnerId: "owner001",
546+
attributes: { type: "Contact" },
547+
Owner: { Email: "owner@example.com" },
548+
},
549+
],
550+
});
551+
552+
const result = await service.getContacts({
553+
emails: "test@example.com",
554+
forRoundRobinSkip: true,
555+
});
556+
557+
expect(result).toEqual([
558+
{
559+
id: "contact001",
560+
email: "test@example.com",
561+
ownerId: "owner001",
562+
ownerEmail: "owner@example.com",
563+
recordType: "Contact",
564+
},
565+
]);
566+
567+
expect(searchSpy).toHaveBeenCalledWith(
568+
"FIND {test@example.com} IN EMAIL FIELDS RETURNING Lead(Id, Email, OwnerId, Owner.Email), Contact(Id, Email, OwnerId, Owner.Email)"
569+
);
570+
});
571+
572+
it("should return lead when no contact exists but lead is found", async () => {
573+
mockAppOptions({
574+
createEventOn: SalesforceRecordEnum.CONTACT,
575+
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.CONTACT,
576+
roundRobinSkipFallbackToLeadOwner: true,
577+
});
578+
579+
const searchSpy = vi.spyOn(mockConnection, "search");
580+
searchSpy.mockResolvedValueOnce({
581+
searchRecords: [
582+
{
583+
Id: "lead001",
584+
Email: "test@example.com",
585+
OwnerId: "owner001",
586+
attributes: { type: "Lead" },
587+
Owner: { Email: "owner@example.com" },
588+
},
589+
],
590+
});
591+
592+
const result = await service.getContacts({
593+
emails: "test@example.com",
594+
forRoundRobinSkip: true,
595+
});
596+
597+
expect(result).toEqual([
598+
{
599+
id: "lead001",
600+
email: "test@example.com",
601+
ownerId: "owner001",
602+
ownerEmail: "owner@example.com",
603+
recordType: "Lead",
604+
},
605+
]);
606+
607+
expect(searchSpy).toHaveBeenCalled();
608+
});
609+
610+
it("should prefer contact over lead when both exist", async () => {
611+
mockAppOptions({
612+
createEventOn: SalesforceRecordEnum.CONTACT,
613+
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.CONTACT,
614+
roundRobinSkipFallbackToLeadOwner: true,
615+
});
616+
617+
const searchSpy = vi.spyOn(mockConnection, "search");
618+
searchSpy.mockResolvedValueOnce({
619+
searchRecords: [
620+
{
621+
Id: "lead001",
622+
Email: "test@example.com",
623+
OwnerId: "owner002",
624+
attributes: { type: "Lead" },
625+
Owner: { Email: "lead-owner@example.com" },
626+
},
627+
{
628+
Id: "contact001",
629+
Email: "test@example.com",
630+
OwnerId: "owner001",
631+
attributes: { type: "Contact" },
632+
Owner: { Email: "contact-owner@example.com" },
633+
},
634+
],
635+
});
636+
637+
const result = await service.getContacts({
638+
emails: "test@example.com",
639+
forRoundRobinSkip: true,
640+
});
641+
642+
expect(result).toEqual([
643+
{
644+
id: "contact001",
645+
email: "test@example.com",
646+
ownerId: "owner001",
647+
ownerEmail: "contact-owner@example.com",
648+
recordType: "Contact",
649+
},
650+
]);
651+
652+
expect(searchSpy).toHaveBeenCalled();
653+
});
654+
655+
it("should return empty array when no records found", async () => {
656+
mockAppOptions({
657+
createEventOn: SalesforceRecordEnum.CONTACT,
658+
roundRobinSkipCheckRecordOn: SalesforceRecordEnum.CONTACT,
659+
roundRobinSkipFallbackToLeadOwner: true,
660+
});
661+
662+
const searchSpy = vi.spyOn(mockConnection, "search");
663+
searchSpy.mockResolvedValueOnce({
664+
searchRecords: [],
665+
});
666+
667+
const result = await service.getContacts({
668+
emails: "test@example.com",
669+
forRoundRobinSkip: true,
670+
});
671+
672+
expect(result).toEqual([]);
673+
expect(searchSpy).toHaveBeenCalled();
674+
});
675+
});
676+
677+
describe("with createEventOnLeadCheckForContact enabled", () => {
678+
it("should find and return contact when it exists", async () => {
679+
mockAppOptions({
680+
createEventOn: SalesforceRecordEnum.LEAD,
681+
createEventOnLeadCheckForContact: true,
682+
});
683+
684+
const searchSpy = vi.spyOn(mockConnection, "search");
685+
searchSpy.mockResolvedValueOnce({
686+
searchRecords: [
687+
{
688+
Id: "contact001",
689+
Email: "test@example.com",
690+
OwnerId: "owner001",
691+
attributes: { type: "Contact" },
692+
Owner: { Email: "owner@example.com" },
693+
},
694+
],
695+
});
696+
697+
const result = await service.getContacts({
698+
emails: "test@example.com",
699+
});
700+
701+
expect(result).toEqual([
702+
{
703+
id: "contact001",
704+
email: "test@example.com",
705+
recordType: "Contact",
706+
},
707+
]);
708+
709+
expect(searchSpy).toHaveBeenCalled();
710+
});
711+
712+
it("should fallback to lead when contact not found", async () => {
713+
mockAppOptions({
714+
createEventOn: SalesforceRecordEnum.LEAD,
715+
createEventOnLeadCheckForContact: true,
716+
});
717+
718+
const searchSpy = vi.spyOn(mockConnection, "search");
719+
searchSpy.mockResolvedValueOnce({
720+
searchRecords: [
721+
{
722+
Id: "lead001",
723+
Email: "test@example.com",
724+
OwnerId: "owner001",
725+
attributes: { type: "Lead" },
726+
Owner: { Email: "owner@example.com" },
727+
},
728+
],
729+
});
730+
731+
const result = await service.getContacts({
732+
emails: "test@example.com",
733+
});
734+
735+
expect(result).toEqual([
736+
{
737+
id: "lead001",
738+
email: "test@example.com",
739+
recordType: "Lead",
740+
},
741+
]);
742+
743+
expect(searchSpy).toHaveBeenCalled();
744+
});
745+
});
746+
});
747+
525748
describe("createContacts", () => {
526749
describe("createEventOn lead", () => {
527750
describe("createNewContactUnderAccount enabled", () => {

0 commit comments

Comments
 (0)