iachat/spec/services/crm/leadsquared/api/lead_client_spec.rb
Shivam Mishra e4c3f0ac2f
feat: fallback on phone number to update lead (#13910)
When syncing contacts to LeadSquared, the `Lead.CreateOrUpdate` API
defaults to searching by email. If a contact has no email (or a
different email) but a phone number matching an existing lead, the API
fails with `MXDuplicateEntryException` instead of finding and updating
the existing lead. This accounted for ~69% of all LeadSquared
integration errors, and cascaded into "Lead not found" failures when
posting transcript and conversation activities (~14% of errors).

## What changed

- `LeadClient#create_or_update_lead` now catches
`MXDuplicateEntryException` and retries the request once with
`SearchBy=Phone` appended to the body, telling the API to match on phone
number instead
- Once the retry succeeds, the returned lead ID is stored on the contact
(existing behavior), so all future events use the direct `update_lead`
path and never hit the duplicate error again

## How to reproduce

1. Create a lead in LeadSquared with phone number `+91-75076767676` and
email `a@example.com`
2. In Chatwoot, create a contact with the same phone number but a
different email (or no email)
3. Trigger a contact sync (via conversation creation or contact update)
4. Before fix: `MXDuplicateEntryException` error in logs, contact fails
to sync
5. After fix: retry with `SearchBy=Phone` finds and updates the existing
lead, stores the lead ID on the contact
2026-03-26 12:32:27 +05:30

357 lines
11 KiB
Ruby

require 'rails_helper'
RSpec.describe Crm::Leadsquared::Api::LeadClient do
let(:access_key) { SecureRandom.hex }
let(:secret_key) { SecureRandom.hex }
let(:headers) do
{
'Content-Type': 'application/json',
'x-LSQ-AccessKey': access_key,
'x-LSQ-SecretKey': secret_key
}
end
let(:endpoint_url) { 'https://api.leadsquared.com/v2' }
let(:client) { described_class.new(access_key, secret_key, endpoint_url) }
describe '#search_lead' do
let(:path) { 'LeadManagement.svc/Leads.GetByQuickSearch' }
let(:search_key) { 'test@example.com' }
let(:full_url) { URI.join(endpoint_url, path).to_s }
context 'when search key is missing' do
it 'raises ArgumentError' do
expect { client.search_lead(nil) }
.to raise_error(ArgumentError, 'Search key is required')
end
end
context 'when no leads are found' do
before do
stub_request(:get, full_url)
.with(query: { key: search_key }, headers: headers)
.to_return(
status: 200,
body: [].to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns empty array directly' do
response = client.search_lead(search_key)
expect(response).to eq([])
end
end
context 'when leads are found' do
let(:lead_data) do
[{
'ProspectID' => SecureRandom.uuid,
'FirstName' => 'John',
'LastName' => 'Doe',
'EmailAddress' => search_key
}]
end
before do
stub_request(:get, full_url)
.with(query: { key: search_key }, headers: headers, body: anything)
.to_return(
status: 200,
body: lead_data.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns lead data array directly' do
response = client.search_lead(search_key)
expect(response).to eq(lead_data)
end
end
end
describe '#create_or_update_lead' do
let(:path) { 'LeadManagement.svc/Lead.CreateOrUpdate' }
let(:full_url) { URI.join(endpoint_url, path).to_s }
let(:lead_data) do
{
'FirstName' => 'John',
'LastName' => 'Doe',
'EmailAddress' => 'john.doe@example.com'
}
end
let(:formatted_lead_data) do
lead_data.map do |key, value|
{
'Attribute' => key,
'Value' => value
}
end
end
context 'when lead data is missing' do
it 'raises ArgumentError' do
expect { client.create_or_update_lead(nil) }
.to raise_error(ArgumentError, 'Lead data is required')
end
end
context 'when lead is successfully created' do
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'Id' => lead_id
}
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns lead ID directly' do
response = client.create_or_update_lead(lead_data)
expect(response).to eq(lead_id)
end
end
context 'when request fails' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionMessage' => 'Error message'
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError' do
expect { client.create_or_update_lead(lead_data) }
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
context 'when request fails with MXDuplicateEntryException and lead has Mobile' do
let(:lead_data) do
{
'FirstName' => 'John',
'EmailAddress' => 'john.doe@example.com',
'Mobile' => '+91-7507684392'
}
end
let(:formatted_lead_data) do
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
end
let(:formatted_lead_data_with_search_by) do
formatted_lead_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }]
end
let(:lead_id) { SecureRandom.uuid }
let(:duplicate_error_response) do
{
'Status' => 'Error',
'ExceptionType' => 'MXDuplicateEntryException',
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
'IsMXException' => true
}
end
let(:success_response) do
{ 'Status' => 'Success', 'Message' => { 'Id' => lead_id } }
end
before do
stub_request(:post, full_url)
.with(body: formatted_lead_data.to_json, headers: headers)
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:post, full_url)
.with(body: formatted_lead_data_with_search_by.to_json, headers: headers)
.to_return(status: 200, body: success_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'retries with SearchBy=Phone and returns lead ID' do
expect(client.create_or_update_lead(lead_data)).to eq(lead_id)
end
end
context 'when request fails with MXDuplicateEntryException but lead has no Mobile' do
let(:duplicate_error_response) do
{
'Status' => 'Error',
'ExceptionType' => 'MXDuplicateEntryException',
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
'IsMXException' => true
}
end
before do
stub_request(:post, full_url)
.with(body: formatted_lead_data.to_json, headers: headers)
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
end
it 'raises ApiError without retrying' do
expect { client.create_or_update_lead(lead_data) }
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
context 'when request fails with a non-duplicate 500 error' do
let(:lead_data) do
{ 'FirstName' => 'John', 'Mobile' => '+91-7507684392' }
end
let(:formatted_lead_data) do
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
end
before do
stub_request(:post, full_url)
.with(body: formatted_lead_data.to_json, headers: headers)
.to_return(
status: 500,
body: { 'Status' => 'Error', 'ExceptionType' => 'SomeOtherException', 'ExceptionMessage' => 'Something else' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError without retrying' do
expect { client.create_or_update_lead(lead_data) }
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
context 'when retry with SearchBy=Phone also fails' do
let(:lead_data) do
{ 'FirstName' => 'John', 'Mobile' => '+91-7507684392' }
end
let(:formatted_lead_data) do
lead_data.map { |key, value| { 'Attribute' => key, 'Value' => value } }
end
let(:formatted_lead_data_with_search_by) do
formatted_lead_data + [{ 'Attribute' => 'SearchBy', 'Value' => 'Phone' }]
end
let(:duplicate_error_response) do
{
'Status' => 'Error',
'ExceptionType' => 'MXDuplicateEntryException',
'ExceptionMessage' => 'A Lead with same Primary Mobile Number already exists.',
'IsMXException' => true
}
end
before do
stub_request(:post, full_url)
.with(body: formatted_lead_data.to_json, headers: headers)
.to_return(status: 500, body: duplicate_error_response.to_json, headers: { 'Content-Type' => 'application/json' })
stub_request(:post, full_url)
.with(body: formatted_lead_data_with_search_by.to_json, headers: headers)
.to_return(
status: 500,
body: { 'Status' => 'Error', 'ExceptionMessage' => 'Still failing' }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError from the retry attempt' do
expect { client.create_or_update_lead(lead_data) }
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
# Add test for update_lead method
describe '#update_lead' do
let(:path) { 'LeadManagement.svc/Lead.Update' }
let(:lead_id) { '8e0f69ae-e2ac-40fc-a0cf-827326181c8a' }
let(:full_url) { URI.join(endpoint_url, "#{path}?leadId=#{lead_id}").to_s }
context 'with missing parameters' do
it 'raises ArgumentError when lead_id is missing' do
expect { client.update_lead(lead_data, nil) }
.to raise_error(ArgumentError, 'Lead ID is required')
end
it 'raises ArgumentError when lead_data is missing' do
expect { client.update_lead(nil, lead_id) }
.to raise_error(ArgumentError, 'Lead data is required')
end
end
context 'when update is successful' do
let(:success_response) do
{
'Status' => 'Success',
'Message' => {
'AffectedRows' => 1
}
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: success_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'returns affected rows directly' do
response = client.update_lead(lead_data, lead_id)
expect(response).to eq(1)
end
end
context 'when update fails' do
let(:error_response) do
{
'Status' => 'Error',
'ExceptionMessage' => 'Invalid lead ID'
}
end
before do
stub_request(:post, full_url)
.with(
body: formatted_lead_data.to_json,
headers: headers
)
.to_return(
status: 200,
body: error_response.to_json,
headers: { 'Content-Type' => 'application/json' }
)
end
it 'raises ApiError' do
expect { client.update_lead(lead_data, lead_id) }
.to(raise_error { |error| expect(error.class.name).to eq('Crm::Leadsquared::Api::BaseClient::ApiError') })
end
end
end
end
end