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>
321 lines
12 KiB
Ruby
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
|