Files
chatwoot/spec/services/crm/leadsquared/processor_service_spec.rb
299bc6c0a4 fix: recover from stale LeadSquared lead ids on activity sync (#14818)
LeadSquared sync now recovers automatically when a contact's cached lead
has been deleted or merged on the LeadSquared side. Previously the stale
lead id was never cleared, so every new conversation or contact update
for that contact failed with "Lead not found"
(`MXInvalidEntityReferenceException`) indefinitely.

## What changed
- Activity sync: on a "Lead not found" error while posting a
conversation/transcript activity, clear the cached `leadsquared_id`,
re-resolve the contact to a fresh lead, and retry the activity once
(guarded against loops and duplicate leads).
- Contact sync: on the same error while updating an existing lead, clear
the cached id and create a fresh lead instead.
- Fix `get_lead_id` to actually return early for unidentifiable contacts
(the guard previously fell through).

## How to reproduce
1. For a LeadSquared-enabled account, point a contact's cached lead id
at a lead that no longer exists in LeadSquared.
2. Update the contact, or create/resolve a conversation for it.
3. Before: the sync fails repeatedly with "Lead not found" and never
self-corrects. After: the stale id is cleared, a fresh lead is
resolved/created, and subsequent syncs reuse the healed id.

---------

Co-authored-by: Tanmay Deep Sharma <32020192+tds-1@users.noreply.github.com>
2026-06-29 16:38:08 +05:30

321 lines
12 KiB
Ruby

require 'rails_helper'
RSpec.describe Crm::Leadsquared::ProcessorService do
let(:account) { create(:account) }
let(:hook) do
create(:integrations_hook, :leadsquared, account: account, settings: {
'access_key' => 'test_access_key',
'secret_key' => 'test_secret_key',
'endpoint_url' => 'https://api.leadsquared.com/v2',
'enable_transcript_activity' => true,
'enable_conversation_activity' => true,
'conversation_activity_code' => 1001,
'transcript_activity_code' => 1002
})
end
let(:contact) { create(:contact, account: account, email: 'test@example.com', phone_number: '+1234567890') }
let(:contact_with_social_profile) do
create(:contact, account: account, additional_attributes: { 'social_profiles' => { 'facebook' => 'chatwootapp' } })
end
let(:blank_contact) { create(:contact, account: account, email: '', phone_number: '') }
let(:conversation) { create(:conversation, account: account, contact: contact) }
let(:service) { described_class.new(hook) }
let(:lead_client) { instance_double(Crm::Leadsquared::Api::LeadClient) }
let(:activity_client) { instance_double(Crm::Leadsquared::Api::ActivityClient) }
let(:lead_finder) { instance_double(Crm::Leadsquared::LeadFinderService) }
before do
account.enable_features('crm_integration')
allow(Crm::Leadsquared::Api::LeadClient).to receive(:new)
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
.and_return(lead_client)
allow(Crm::Leadsquared::Api::ActivityClient).to receive(:new)
.with('test_access_key', 'test_secret_key', 'https://api.leadsquared.com/v2')
.and_return(activity_client)
allow(Crm::Leadsquared::LeadFinderService).to receive(:new)
.with(lead_client)
.and_return(lead_finder)
end
describe '.crm_name' do
it 'returns leadsquared' do
expect(described_class.crm_name).to eq('leadsquared')
end
end
describe '#handle_contact' do
context 'when contact is valid' do
before do
allow(service).to receive(:identifiable_contact?).and_return(true)
end
context 'when contact has no stored lead ID' do
before do
contact.update(additional_attributes: { 'external' => nil })
contact.reload
allow(lead_client).to receive(:create_or_update_lead)
.with(any_args)
.and_return('new_lead_id')
end
it 'creates a new lead and stores the ID' do
service.handle_contact(contact)
expect(lead_client).to have_received(:create_or_update_lead).with(any_args)
expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('new_lead_id')
end
end
context 'when contact has existing lead ID' do
before do
contact.additional_attributes = { 'external' => { 'leadsquared_id' => 'existing_lead_id' } }
contact.save!
allow(lead_client).to receive(:update_lead)
.with(any_args)
.and_return(nil) # The update method doesn't need to return anything
end
it 'updates the lead using existing ID' do
service.handle_contact(contact)
expect(lead_client).to have_received(:update_lead).with(any_args)
end
end
context 'when the existing lead no longer exists' do
let(:error_response) do
instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXInvalidEntityReferenceException' })
end
let(:lead_not_found_error) do
Crm::Leadsquared::Api::BaseClient::ApiError.new('Lead not found', 500, error_response)
end
before do
contact.update!(additional_attributes: { 'external' => { 'leadsquared_id' => 'stale_lead_id' } })
allow(lead_client).to receive(:update_lead)
.with(any_args, 'stale_lead_id')
.and_raise(lead_not_found_error)
allow(lead_client).to receive(:update_lead)
.with(any_args, 'fresh_lead_id')
.and_return(nil)
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('fresh_lead_id')
end
it 'clears the stale id and re-resolves the lead' do
service.handle_contact(contact)
expect(lead_finder).to have_received(:find_or_create).with(contact)
expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('fresh_lead_id')
end
end
context 'when API call raises an error' do
before do
allow(lead_client).to receive(:create_or_update_lead)
.with(any_args)
.and_raise(Crm::Leadsquared::Api::BaseClient::ApiError.new('API Error'))
allow(Rails.logger).to receive(:error)
end
it 'catches and logs the error' do
service.handle_contact(contact)
expect(Rails.logger).to have_received(:error).with(/LeadSquared API error/)
end
end
end
context 'when contact is invalid' do
before do
allow(service).to receive(:identifiable_contact?).and_return(false)
allow(lead_client).to receive(:create_or_update_lead)
end
it 'returns without making API calls' do
service.handle_contact(blank_contact)
expect(lead_client).not_to have_received(:create_or_update_lead)
end
end
end
describe '#handle_conversation_created' do
let(:activity_note) { 'New conversation started' }
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_conversation_activity)
.with(hook, conversation)
.and_return(activity_note)
end
context 'when conversation activities are enabled' do
before do
service.instance_variable_set(:@allow_conversation, true)
end
context 'when lead_id is found' do
before do
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1001, activity_note)
.and_return('test_activity_id')
end
it 'creates the activity and stores metadata' do
service.handle_conversation_created(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('test_activity_id')
end
end
context 'when post_activity raises an error' do
before do
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1001, activity_note)
.and_raise(StandardError.new('Activity error'))
allow(Rails.logger).to receive(:error)
end
it 'logs the error' do
service.handle_conversation_created(conversation)
expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/)
end
end
context 'when post_activity fails because the lead no longer exists' do
let(:error_response) do
instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXInvalidEntityReferenceException' })
end
let(:lead_not_found_error) do
Crm::Leadsquared::Api::BaseClient::ApiError.new('Lead not found', 500, error_response)
end
before do
contact.update!(additional_attributes: { 'external' => { 'leadsquared_id' => 'stale_lead_id' } })
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('stale_lead_id', 'fresh_lead_id')
allow(activity_client).to receive(:post_activity)
.with('stale_lead_id', 1001, activity_note)
.and_raise(lead_not_found_error)
allow(activity_client).to receive(:post_activity)
.with('fresh_lead_id', 1001, activity_note)
.and_return('healed_activity_id')
end
it 'clears the stale id, re-resolves the lead, and retries the activity once' do
service.handle_conversation_created(conversation)
expect(activity_client).to have_received(:post_activity).with('fresh_lead_id', 1001, activity_note)
expect(contact.reload.additional_attributes['external']['leadsquared_id']).to eq('fresh_lead_id')
expect(conversation.reload.additional_attributes['leadsquared']['created_activity_id']).to eq('healed_activity_id')
end
end
context 'when post_activity fails with a non-recoverable error' do
let(:error_response) do
instance_double(HTTParty::Response, blank?: false, parsed_response: { 'ExceptionType' => 'MXSomeOtherException' })
end
let(:other_error) do
Crm::Leadsquared::Api::BaseClient::ApiError.new('boom', 500, error_response)
end
before do
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity).and_raise(other_error)
allow(Rails.logger).to receive(:error)
end
it 'logs once and does not retry' do
service.handle_conversation_created(conversation)
expect(activity_client).to have_received(:post_activity).once
expect(Rails.logger).to have_received(:error).with(/LeadSquared conversation activity failed/)
end
end
end
context 'when conversation activities are disabled' do
before do
service.instance_variable_set(:@allow_conversation, false)
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_created(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
end
describe '#handle_conversation_resolved' do
let(:activity_note) { 'Conversation transcript' }
before do
allow(Crm::Leadsquared::Mappers::ConversationMapper).to receive(:map_transcript_activity)
.with(hook, conversation)
.and_return(activity_note)
end
context 'when transcript activities are enabled and conversation is resolved' do
before do
service.instance_variable_set(:@allow_transcript, true)
conversation.update!(status: 'resolved')
allow(lead_finder).to receive(:find_or_create)
.with(contact)
.and_return('test_lead_id')
allow(activity_client).to receive(:post_activity)
.with('test_lead_id', 1002, activity_note)
.and_return('test_activity_id')
end
it 'creates the transcript activity and stores metadata' do
service.handle_conversation_resolved(conversation)
expect(conversation.reload.additional_attributes['leadsquared']['transcript_activity_id']).to eq('test_activity_id')
end
end
context 'when conversation is not resolved' do
before do
service.instance_variable_set(:@allow_transcript, true)
conversation.update!(status: 'open')
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_resolved(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
context 'when transcript activities are disabled' do
before do
service.instance_variable_set(:@allow_transcript, false)
conversation.update!(status: 'resolved')
allow(activity_client).to receive(:post_activity)
end
it 'does not create an activity' do
service.handle_conversation_resolved(conversation)
expect(activity_client).not_to have_received(:post_activity)
end
end
end
end