Merge branch 'feature/cw-7513' into feature/cw-7513-specs

This commit is contained in:
Tanmay Deep Sharma
2026-07-15 12:42:54 +05:30
117 changed files with 8802 additions and 151 deletions
@@ -15,7 +15,7 @@ RSpec.describe 'Agent Bot API', type: :request do
end
end
context 'when it is an authenticated user' do
context 'when it is an authenticated agent' do
it 'returns all the agent_bots in account along with global agent bots' do
global_bot = create(:agent_bot)
get "/api/v1/accounts/#{account.id}/agent_bots",
@@ -25,7 +25,7 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.name)
expect(response.body).to include(global_bot.name)
expect(response.body).to include(agent_bot.access_token.token)
expect(response.body).not_to include(agent_bot.access_token.token)
expect(response.body).not_to include(global_bot.access_token.token)
end
@@ -54,6 +54,17 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(account_bot_response).to include('thumbnail')
end
end
context 'when it is an authenticated administrator' do
it 'returns the account bot access token' do
get "/api/v1/accounts/#{account.id}/agent_bots",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.access_token.token)
end
end
end
describe 'GET /api/v1/accounts/{account.id}/agent_bots/:id' do
@@ -65,7 +76,7 @@ RSpec.describe 'Agent Bot API', type: :request do
end
end
context 'when it is an authenticated user' do
context 'when it is an authenticated agent' do
it 'shows the agent bot' do
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
headers: agent.create_new_auth_token,
@@ -73,7 +84,7 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.name)
expect(response.body).to include(agent_bot.access_token.token)
expect(response.body).not_to include(agent_bot.access_token.token)
end
it 'will show a global agent bot' do
@@ -91,6 +102,17 @@ RSpec.describe 'Agent Bot API', type: :request do
expect(response.parsed_body).not_to include('outgoing_url')
end
end
context 'when it is an authenticated administrator' do
it 'returns the account bot access token' do
get "/api/v1/accounts/#{account.id}/agent_bots/#{agent_bot.id}",
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.body).to include(agent_bot.access_token.token)
end
end
end
describe 'POST /api/v1/accounts/{account.id}/agent_bots' do
@@ -70,8 +70,8 @@ RSpec.describe 'DashboardAppsController', type: :request do
end
end
context 'when it is an authenticated user' do
let(:user) { create(:user, account: account) }
context 'when it is an authenticated administrator' do
let(:user) { create(:user, account: account, role: :administrator) }
it 'creates the dashboard app' do
expect do
@@ -130,11 +130,26 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(response).to have_http_status(:unprocessable_entity)
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'does not create account-wide dashboard apps' do
expect do
post "/api/v1/accounts/#{account.id}/dashboard_apps",
headers: agent.create_new_auth_token,
params: payload,
as: :json
end.not_to change(DashboardApp, :count)
expect(response).to have_http_status(:unauthorized)
end
end
end
describe 'PATCH /api/v1/accounts/{account.id}/dashboard_apps/:id' do
let(:payload) { { dashboard_app: { title: 'CRM Dashboard', content: [{ type: 'frame', url: 'https://link.com' }] } } }
let(:user) { create(:user, account: account) }
let(:user) { create(:user, account: account, role: :administrator) }
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
context 'when it is an unauthenticated user' do
@@ -160,10 +175,24 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(json_response['content'][0]['type']).to eq payload[:dashboard_app][:content][0][:type]
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'does not update account-wide dashboard apps' do
patch "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
headers: agent.create_new_auth_token,
params: payload,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(dashboard_app.reload.title).not_to eq('CRM Dashboard')
end
end
end
describe 'DELETE /api/v1/accounts/{account.id}/dashboard_apps/:id' do
let(:user) { create(:user, account: account) }
let(:user) { create(:user, account: account, role: :administrator) }
let!(:dashboard_app) { create(:dashboard_app, user: user, account: account) }
context 'when it is an unauthenticated user' do
@@ -182,5 +211,18 @@ RSpec.describe 'DashboardAppsController', type: :request do
expect(user.dashboard_apps.count).to be 0
end
end
context 'when it is an authenticated agent' do
let(:agent) { create(:user, account: account, role: :agent) }
it 'does not delete account-wide dashboard apps' do
delete "/api/v1/accounts/#{account.id}/dashboard_apps/#{dashboard_app.id}",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unauthorized)
expect(DashboardApp.exists?(dashboard_app.id)).to be(true)
end
end
end
end
@@ -51,6 +51,18 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:payload].length).to eq(5)
expect(json_response[:meta]).to eq({ page: 2, total_count: 30 })
end
it 'returns the generated FAQ count for each document' do
document = create(:captain_document, assistant: assistant, account: account)
create_list(:captain_assistant_response, 2,
assistant: assistant, account: account, documentable: document)
get "/api/v1/accounts/#{account.id}/captain/documents",
headers: agent.create_new_auth_token, as: :json
matching_document = json_response[:payload].find { |item| item[:id] == document.id }
expect(matching_document[:responses_count]).to eq(2)
end
end
context 'when filtering by assistant_id' do
@@ -142,6 +154,10 @@ RSpec.describe 'Api::V1::Accounts::Captain::Documents', type: :request do
expect(json_response[:external_link]).to eq(document.external_link)
end
it 'returns the crawled content for the document' do
expect(json_response[:content]).to eq(document.content)
end
it 'returns sync metadata when the document has been synced' do
synced_at = 1.hour.ago
document.update!(sync_status: :synced, last_synced_at: synced_at)
@@ -68,6 +68,110 @@ RSpec.describe Captain::ConversationCompletionService do
end
end
context 'when building evaluation context' do
let(:captain_assistant) { create(:captain_assistant, account: account) }
let(:mock_response) do
instance_double(
RubyLLM::Message,
content: { 'complete' => false, 'reason' => 'Human follow-up is still pending' },
input_tokens: 100,
output_tokens: 20
)
end
it 'includes conversation status and speaker labels' do
conversation.update!(status: :pending, waiting_since: 2.hours.ago)
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'I need help with a refund')
create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'I will transfer this to support for review.'
)
expect(mock_chat).to receive(:ask) do |content|
expect(content).to include(
'Conversation status: pending',
'Conversation transcript:',
'Customer: I need help with a refund',
'Captain: I will transfer this to support for review.'
)
mock_response
end
result = service.perform
expect(result[:complete]).to be false
end
it 'includes pending captain handoff evidence in the transcript' do
conversation.update!(status: :pending)
create(:message, conversation: conversation, inbox: inbox, account: account, message_type: :incoming, content: 'Please cancel my order')
create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'I will transfer this to a specialist and they will follow up here.'
)
expect(mock_chat).to receive(:ask) do |content|
expect(content).to include(
'Conversation status: pending',
'Captain: I will transfer this to a specialist and they will follow up here.'
)
mock_response
end
result = service.perform
expect(result[:complete]).to be false
end
it 'reuses computed message content while formatting the transcript' do
content_for_llm_calls_by_message_id = Hash.new(0)
allow_any_instance_of(Message).to receive(:content_for_llm).and_wrap_original do |method, *args| # rubocop:disable RSpec/AnyInstance
content_for_llm_calls_by_message_id[method.receiver.id] += 1
method.call(*args)
end
incoming_message = create(
:message,
:with_attachment,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :incoming,
content: nil
)
outgoing_message = create(
:message,
conversation: conversation,
inbox: inbox,
account: account,
message_type: :outgoing,
sender: captain_assistant,
content: 'What do you need help with?'
)
allow(mock_chat).to receive(:ask).and_return(mock_response)
service.perform
expect(content_for_llm_calls_by_message_id).to include(
incoming_message.id => 1,
outgoing_message.id => 1
)
end
end
context 'when conversation has no messages' do
it 'returns incomplete with appropriate reason' do
result = service.perform
@@ -0,0 +1,160 @@
require 'rails_helper'
RSpec.describe Captain::AgentSession, type: :model do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:assistant).class_name('Captain::Assistant') }
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:subject) }
it { is_expected.to belong_to(:result).optional }
end
describe 'enums' do
it { is_expected.to define_enum_for(:session_type).with_values(assistant: 0, copilot: 1).with_prefix(:session) }
end
describe '#subject' do
it 'returns the conversation for an assistant session' do
conversation = create(:conversation, account: account)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
expect(session.subject).to eq(conversation)
end
it 'returns the copilot thread for a copilot session' do
user = create(:user, account: account)
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user, subject: copilot_thread)
expect(session.subject).to eq(copilot_thread)
end
it 'returns nil when the subject record no longer exists' do
conversation = create(:conversation, account: account)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation)
conversation.destroy
expect(session.reload.subject).to be_nil
end
it 'is not valid when the subject type does not match the session type' do
copilot_thread = create(:captain_copilot_thread, account: account, user: create(:user, account: account), assistant: assistant)
session = build(:captain_agent_session, account: account, assistant: assistant, subject: copilot_thread)
expect(session).not_to be_valid
expect(session.errors[:subject_type]).to be_present
end
it 'is not valid when the subject belongs to a different account' do
foreign_conversation = create(:conversation, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: foreign_conversation)
expect(session).not_to be_valid
expect(session.errors[:subject]).to be_present
end
end
describe '#result' do
it 'returns the message for an assistant session' do
conversation = create(:conversation, account: account)
message = create(:message, account: account, conversation: conversation)
session = create(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: message)
expect(session.result).to eq(message)
end
it 'returns the copilot message for a copilot session' do
user = create(:user, account: account)
copilot_thread = create(:captain_copilot_thread, account: account, user: user, assistant: assistant)
copilot_message = create(:captain_copilot_message, account: account, copilot_thread: copilot_thread)
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant, user: user,
subject: copilot_thread, result: copilot_message)
expect(session.result).to eq(copilot_message)
end
it 'returns nil when result_id is nil' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session.result).to be_nil
end
it 'is not valid when the result belongs to a different account' do
conversation = create(:conversation, account: account)
foreign_message = create(:message, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation, result: foreign_message)
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
it 'is not valid when result_id/result_type are set directly for a different account' do
conversation = create(:conversation, account: account)
foreign_message = create(:message, account: create(:account))
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
result_id: foreign_message.id, result_type: 'Message')
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
it 'is not valid when result_id/result_type are set directly for a stale id' do
conversation = create(:conversation, account: account)
session = build(:captain_agent_session, account: account, assistant: assistant, subject: conversation,
result_id: 0, result_type: 'Message')
expect(session).not_to be_valid
expect(session.errors[:result]).to be_present
end
end
describe 'account' do
it 'is derived from the assistant when created via the assistant association' do
conversation = create(:conversation, account: account)
session = assistant.agent_sessions.create!(subject: conversation, session_type: :assistant)
expect(session.account).to eq(account)
end
it 'overrides a mismatched explicit account with the assistant account' do
conversation = create(:conversation, account: account)
session = build(:captain_agent_session, account: create(:account), assistant: assistant, subject: conversation)
expect(session).to be_valid
expect(session.account).to eq(account)
end
end
describe 'defaults' do
it 'defaults faq_ids, document_ids, scenario_ids and run_context' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session.faq_ids).to eq([])
expect(session.document_ids).to eq([])
expect(session.scenario_ids).to eq([])
expect(session.run_context).to eq({})
end
end
describe 'factory' do
it 'builds a valid assistant session' do
session = create(:captain_agent_session, account: account, assistant: assistant)
expect(session).to be_valid
expect(session).to be_session_assistant
expect(session.subject).to be_a(Conversation)
end
it 'builds a valid copilot session' do
session = create(:captain_agent_session, :copilot, account: account, assistant: assistant)
expect(session).to be_valid
expect(session).to be_session_copilot
expect(session.subject).to be_a(CopilotThread)
expect(session.user).to be_present
end
end
end
@@ -0,0 +1,42 @@
require 'rails_helper'
RSpec.describe Captain::Assistant do
describe '#agent_tools' do
let(:account) { create(:account) }
let(:assistant) { create(:captain_assistant, account: account) }
it 'includes enabled custom tools from the assistant account' do
custom_tool = create(:captain_custom_tool, account: account)
tools = assistant.send(:agent_tools)
expect(tools.map(&:name)).to include(custom_tool.slug)
expect(tools.find { |tool| tool.name == custom_tool.slug }).to be_a(Captain::Tools::HttpTool)
end
it 'excludes disabled custom tools' do
custom_tool = create(:captain_custom_tool, :disabled, account: account)
tools = assistant.send(:agent_tools)
expect(tools.map(&:name)).not_to include(custom_tool.slug)
end
it 'excludes custom tools from other accounts' do
custom_tool = create(:captain_custom_tool)
tools = assistant.send(:agent_tools)
expect(tools.map(&:name)).not_to include(custom_tool.slug)
end
it 'keeps the built-in FAQ lookup and handoff tools' do
tools = assistant.send(:agent_tools)
expect(tools).to include(
an_instance_of(Captain::Tools::FaqLookupTool),
an_instance_of(Captain::Tools::HandoffTool)
)
end
end
end
@@ -0,0 +1,116 @@
require 'rails_helper'
RSpec.describe Captain::AssistantMigration::DraftApplier do
let(:account) { create(:account) }
let(:assistant) do
create(
:captain_assistant,
account: account,
config: { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
response_guidelines: [],
guardrails: []
)
end
let(:scenario_candidate) do
{
'title' => 'Billing Investigation',
'description' => 'Use when a customer reports an account-specific billing issue.',
'instruction' => 'Collect the invoice number and summarize the issue before escalating.',
'response_guideline' => 'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.',
'tool_ids' => []
}
end
let(:faq_document_candidate) do
{
'question' => 'When is support available?',
'answer' => 'Support is available Monday to Friday.'
}
end
let(:draft) do
{
business_product_context: ['Support assistant for Test Product.'],
response_guidelines: ['Be concise.'],
guardrails: ['Do not guess.'],
conversation_messages: {},
scenario_candidates: [scenario_candidate],
faq_document_candidates: [faq_document_candidate],
needs_review: ['Pricing details are missing because factual details are absent from the source instructions.']
}
end
describe '#perform' do
it 'reports staged scenario candidates in dry run without writing to the assistant' do
result = described_class.new(assistant: assistant, draft: draft, dry_run: true).perform
expect(result.dig(:changes, :config, :to, 'assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
expect(result.dig(:changes, :response_guidelines, :to)).to include(
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
)
expect(assistant.reload.config).not_to have_key('assistant_migration')
expect(assistant.scenarios.count).to eq(0)
end
it 'stores scenario candidates in assistant config and flattens them into response guidelines' do
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
assistant.reload
expect(assistant.config.dig('assistant_migration', 'scenario_candidates')).to eq([scenario_candidate])
expect(assistant.config.dig('assistant_migration', 'faq_document_candidates')).to contain_exactly(faq_document_candidate)
expect(assistant.config.dig('assistant_migration', 'needs_review')).to contain_exactly(
'Pricing details are missing because factual details are absent from the source instructions.'
)
expect(assistant.response_guidelines).to include(
'For account-specific billing issues, collect the invoice number and summarize the issue before escalating.'
)
expect(assistant.response_guidelines).not_to include(faq_document_candidate['answer'])
expect(assistant.scenarios.count).to eq(0)
end
it 'rejects stale drafts whose FAQ candidates use the old string format' do
stale_draft = draft.merge(faq_document_candidates: ['Support is available Monday to Friday.'])
expect do
described_class.new(assistant: assistant, draft: stale_draft, dry_run: false).perform
end.to raise_error(ArgumentError, 'FAQ document candidates must be question and answer objects')
expect(assistant.reload.config).not_to have_key('assistant_migration')
end
it 'preserves original values in migration config before applying classifier output' do
assistant.update!(
description: 'Existing assistant description.',
response_guidelines: ['Use plain language.'],
guardrails: ['Do not disclose internal notes.']
)
described_class.new(assistant: assistant, draft: draft, dry_run: false).perform
assistant.reload
expect(assistant.description).to eq('Support assistant for Test Product.')
expect(assistant.response_guidelines).to include('Be concise.')
expect(assistant.guardrails).to eq(['Do not guess.'])
expect(assistant.config.dig('assistant_migration', 'original_values')).to include(
'name' => assistant.name,
'description' => 'Existing assistant description.',
'config' => { 'product_name' => 'Test Product', 'instructions' => 'Legacy V1 custom instructions.' },
'response_guidelines' => ['Use plain language.'],
'guardrails' => ['Do not disclose internal notes.']
)
end
it 'rejects an oversized assistant description from a stale draft' do
long_context = 'This assistant supports a very broad product surface with many long details. ' * 10
original_description = assistant.description
expect do
described_class.new(
assistant: assistant,
draft: draft.merge(business_product_context: [long_context]),
dry_run: false
).perform
end.to raise_error(ArgumentError, 'Assistant description exceeds 500 characters')
expect(assistant.reload.description).to eq(original_description)
end
end
end
@@ -0,0 +1,53 @@
require 'rails_helper'
describe Enterprise::Billing::ReconcilePlanFeaturesService do
let(:account) { create(:account) }
before do
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{ 'name' => 'Hacker', 'product_id' => ['plan_id_hacker'], 'price_ids' => ['price_hacker'] },
{ 'name' => 'Startups', 'product_id' => ['plan_id_startups'], 'price_ids' => ['price_startups'] }
]
})
end
describe '#perform' do
context 'with api_and_webhooks feature' do
it 'enables the feature for a paid plan with an active subscription' do
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'active' })
described_class.new(account: account).perform
expect(account.reload).to be_feature_enabled('api_and_webhooks')
end
it 'enables the feature for a paid plan on trial' do
account.update!(custom_attributes: { 'plan_name' => 'Startups', 'subscription_status' => 'trialing' })
described_class.new(account: account).perform
expect(account.reload).to be_feature_enabled('api_and_webhooks')
end
it 'disables the feature on the default plan' do
account.enable_features!('api_and_webhooks')
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'active' })
described_class.new(account: account).perform
expect(account.reload).not_to be_feature_enabled('api_and_webhooks')
end
it 'keeps the feature enabled when manually managed' do
account.update!(custom_attributes: { 'plan_name' => 'Hacker', 'subscription_status' => 'trialing' })
Internal::Accounts::InternalAttributesService.new(account).manually_managed_features = ['api_and_webhooks']
described_class.new(account: account).perform
expect(account.reload).to be_feature_enabled('api_and_webhooks')
end
end
end
end
+14
View File
@@ -0,0 +1,14 @@
FactoryBot.define do
factory :captain_agent_session, class: 'Captain::AgentSession' do
account
association :assistant, factory: :captain_assistant
session_type { :assistant }
subject { create(:conversation, account: account) }
trait :copilot do
session_type { :copilot }
user
subject { create(:captain_copilot_thread, account: account, user: user) }
end
end
end
+9
View File
@@ -3,5 +3,14 @@ FactoryBot.define do
data_type { 'contacts' }
import_file { Rack::Test::UploadedFile.new(Rails.root.join('spec/assets/contacts.csv'), 'text/csv') }
account
trait :intercom do
data_type { 'intercom' }
source_type { 'api' }
source_provider { 'intercom' }
import_types { %w[contacts conversations] }
access_token { 'intercom-token' }
import_file { nil }
end
end
end
@@ -0,0 +1,26 @@
require 'rails_helper'
RSpec.describe DataImportErrorFinder do
let(:data_import) { create(:data_import, :intercom) }
it 'returns only the latest five non-skip errors' do
6.times do |index|
data_import.import_errors.create!(
error_code: 'Intercom::Error',
source_object_id: "error_#{index}",
details: { kind: 'run_error' },
created_at: Time.zone.at(index)
)
end
data_import.import_errors.create!(
error_code: 'Intercom::Skipped',
source_object_id: 'skipped_error',
details: { kind: 'skipped' },
created_at: Time.zone.at(10)
)
errors = described_class.new(data_import).import_errors
expect(errors.pluck(:source_object_id)).to eq(%w[error_5 error_4 error_3 error_2 error_1])
end
end
@@ -0,0 +1,38 @@
require 'rails_helper'
RSpec.describe DataImportSkipLogFinder do
let(:data_import) { create(:data_import, :intercom) }
before do
6.times do |index|
data_import.import_errors.create!(
error_code: 'Intercom::Skipped',
source_object_type: 'message',
source_object_id: "message_#{index}",
details: { kind: 'skipped' },
created_at: Time.zone.at(index)
)
end
data_import.import_errors.create!(
error_code: 'Intercom::Skipped',
source_object_type: 'contact',
source_object_id: 'contact_1',
details: { kind: 'skipped' }
)
end
it 'filters skip logs and returns only the latest five', :aggregate_failures do
finder = described_class.new(data_import, skip_logs_type: 'message')
expect(finder.skip_logs.pluck(:source_object_id)).to eq(%w[message_5 message_4 message_3 message_2 message_1])
expect(finder.selected_source_object_type).to eq('message')
expect(finder.counts_by_type).to include('message' => 6, 'contact' => 1)
end
it 'ignores unsupported source object filters' do
finder = described_class.new(data_import, skip_logs_type: 'company')
expect(finder.selected_source_object_type).to be_nil
expect(finder.skip_logs.size).to eq(5)
end
end
@@ -0,0 +1,218 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::ImportJob do
let(:account) { create(:account) }
let(:data_import) do
create(
:data_import, :intercom,
account: account,
import_types: %w[contacts conversations]
)
end
let(:importer) { instance_double(DataImports::Intercom::Importer) }
let(:run_id) { 'intercom-run-1' }
before do
account.enable_features!('data_import')
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id })
allow(DataImports::Intercom::Importer).to receive(:new).with(data_import: data_import, run_id: run_id).and_return(importer)
end
describe DataImports::Intercom::BaseJob do
it 'checks rate limit retry before the generic client retry' do
expect(described_class.rescue_handlers.last.first).to eq('DataImports::Intercom::Client::RateLimitError')
end
end
describe DataImports::Intercom::ImportJob do
it 'starts the import and enqueues the first contacts page' do
allow(importer).to receive_messages(start!: true, import_contacts?: true, contacts_completed?: false, cursor_for: 'contact-cursor')
expect do
described_class.perform_now(data_import, run_id)
end.to have_enqueued_job(DataImports::Intercom::ContactsPageJob).with(data_import, 'contact-cursor', run_id).on_queue('low')
expect(importer).to have_received(:start!)
end
it 'resumes at conversations when contacts are already completed' do
allow(importer).to receive_messages(
start!: true,
import_contacts?: true,
contacts_completed?: true,
import_conversations?: true,
conversations_completed?: false
)
allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor')
expect do
described_class.perform_now(data_import, run_id)
end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id)
end
it 'finishes immediately when every requested stage is already complete' do
allow(importer).to receive_messages(
start!: true,
import_contacts?: true,
contacts_completed?: true,
import_conversations?: true,
conversations_completed?: true,
finish!: true
)
expect do
described_class.perform_now(data_import, run_id)
end.not_to have_enqueued_job
expect(importer).to have_received(:finish!)
end
it 'skips stale import jobs from an earlier run' do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
expect(DataImports::Intercom::Importer).not_to receive(:new)
described_class.perform_now(data_import, 'old-run')
end
end
describe DataImports::Intercom::ContactsPageJob do
it 'hands off to conversations when a retry finds contacts already completed' do
allow(importer).to receive_messages(
contacts_completed?: true,
import_conversations?: true,
conversations_completed?: false
)
allow(importer).to receive(:cursor_for).with('conversations').and_return('conversation-cursor')
expect(importer).not_to receive(:import_contacts_page)
expect do
described_class.perform_now(data_import, 'completed-contact-cursor', run_id)
end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, 'conversation-cursor', run_id)
end
it 'imports one contacts page and enqueues the next contacts page' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-contact-cursor')
allow(importer).to receive_messages(contacts_completed?: false)
allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor').and_return(result)
expect do
described_class.perform_now(data_import, 'current-contact-cursor', run_id)
end.to have_enqueued_job(described_class).with(data_import, 'next-contact-cursor', run_id)
end
it 'hands off to conversations after the final contacts page' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
allow(importer).to receive_messages(
contacts_completed?: false,
import_conversations?: true,
conversations_completed?: false
)
allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result)
allow(importer).to receive(:cursor_for).with('conversations').and_return(nil)
expect do
described_class.perform_now(data_import, nil, run_id)
end.to have_enqueued_job(DataImports::Intercom::ConversationsPageJob).with(data_import, nil, run_id)
end
it 'finishes after the final contacts page when conversations are not requested' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
allow(importer).to receive_messages(contacts_completed?: false, import_conversations?: false, finish!: true)
allow(importer).to receive(:import_contacts_page).with(starting_after: nil).and_return(result)
expect do
described_class.perform_now(data_import, nil, run_id)
end.not_to have_enqueued_job
expect(importer).to have_received(:finish!)
end
it 'skips stale page jobs from an earlier run' do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
expect(DataImports::Intercom::Importer).not_to receive(:new)
described_class.perform_now(data_import, 'current-contact-cursor', 'old-run')
end
it 'skips failed page jobs from backend retries' do
data_import.update!(status: :failed)
expect(DataImports::Intercom::Importer).not_to receive(:new)
described_class.perform_now(data_import, 'current-contact-cursor', run_id)
end
it 'does not enqueue another stage when the page import becomes stale' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
allow(importer).to receive_messages(contacts_completed?: false, finish!: true)
allow(importer).to receive(:import_contacts_page).with(starting_after: 'current-contact-cursor') do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
result
end
expect do
described_class.perform_now(data_import, 'current-contact-cursor', run_id)
end.not_to have_enqueued_job
expect(importer).not_to have_received(:finish!)
end
end
describe DataImports::Intercom::ConversationsPageJob do
it 'finishes when a retry finds conversations already completed' do
allow(importer).to receive_messages(conversations_completed?: true, finish!: true)
expect(importer).not_to receive(:import_conversations_page)
described_class.perform_now(data_import, 'completed-conversation-cursor', run_id)
expect(importer).to have_received(:finish!)
end
it 'imports one conversations page and enqueues the next conversations page' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: 'next-conversation-cursor')
allow(importer).to receive_messages(conversations_completed?: false)
allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor').and_return(result)
expect do
described_class.perform_now(data_import, 'current-conversation-cursor', run_id)
end.to have_enqueued_job(described_class).with(data_import, 'next-conversation-cursor', run_id)
end
it 'finishes after the final conversations page' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
allow(importer).to receive_messages(conversations_completed?: false, finish!: true)
allow(importer).to receive(:import_conversations_page).with(starting_after: nil).and_return(result)
expect do
described_class.perform_now(data_import, nil, run_id)
end.not_to have_enqueued_job
expect(importer).to have_received(:finish!)
end
it 'skips stale page jobs from an earlier run' do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
expect(DataImports::Intercom::Importer).not_to receive(:new)
described_class.perform_now(data_import, 'current-conversation-cursor', 'old-run')
end
it 'does not finish when the page import becomes stale' do
result = DataImports::Intercom::Importer::PageResult.new(next_cursor: nil)
allow(importer).to receive_messages(conversations_completed?: false, finish!: true)
allow(importer).to receive(:import_conversations_page).with(starting_after: 'current-conversation-cursor') do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
result
end
expect do
described_class.perform_now(data_import, 'current-conversation-cursor', run_id)
end.not_to have_enqueued_job
expect(importer).not_to have_received(:finish!)
end
end
end
+8 -2
View File
@@ -108,6 +108,10 @@ RSpec.describe Account do
it 'configures the account feature flag extension column' do
expect(described_class.flag_columns).to include('feature_flags', 'feature_flags_ext_1')
expect(described_class.flag_mapping['feature_flags_ext_1']).to eq(feature_whatsapp_manual_transfer: 1, feature_data_import: 1 << 1,
feature_api_and_webhooks: 1 << 2)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_whatsapp_manual_transfer]).to eq(1)
expect(described_class.flag_mapping['feature_flags_ext_1'][:feature_data_import]).to eq(2)
end
it 'keeps existing feature flags on the original column' do
@@ -116,15 +120,17 @@ RSpec.describe Account do
end
it 'keeps bulk selected feature assignment compatible with existing feature names' do
account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment]
account.selected_feature_flags = [:feature_ip_lookup, :feature_assignment_v2, :feature_advanced_assignment, :feature_data_import]
expect(account).to be_feature_ip_lookup
expect(account).to be_feature_assignment_v2
expect(account).to be_feature_advanced_assignment
expect(account).to be_feature_data_import
expect(account.selected_feature_flags).to contain_exactly(
:feature_ip_lookup,
:feature_assignment_v2,
:feature_advanced_assignment
:feature_advanced_assignment,
:feature_data_import
)
end
end
+44
View File
@@ -11,6 +11,18 @@ RSpec.describe DataImport do
end
end
describe 'access token encryption' do
it 'encrypts the Intercom access token at rest' do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
data_import = create(:data_import, :intercom, access_token: 'intercom-secret')
stored_value = data_import.reload.read_attribute_before_type_cast(:access_token).to_s
expect(stored_value).not_to include('intercom-secret')
expect(data_import.access_token).to eq('intercom-secret')
end
end
describe 'callbacks' do
let(:data_import) { build(:data_import) }
@@ -20,4 +32,36 @@ RSpec.describe DataImport do
end.to have_enqueued_job(DataImportJob).with(data_import).on_queue('low')
end
end
describe '#abandon!' do
let(:account) { create(:account) }
let(:data_import) do
create(
:data_import, :intercom,
account: account,
status: :processing
)
end
before do
account.enable_features!('data_import')
end
it 'abandons active Intercom imports', :aggregate_failures do
data_import.abandon!
expect(data_import).to be_abandoned
expect(data_import.abandoned_at).to be_present
end
it 'does not overwrite terminal status from a stale instance', :aggregate_failures do
stale_import = described_class.find(data_import.id)
data_import.update!(status: :completed, completed_at: 1.minute.ago)
stale_import.abandon!
expect(data_import.reload).to be_completed
expect(data_import.abandoned_at).to be_nil
end
end
end
@@ -0,0 +1,438 @@
require 'rails_helper'
RSpec.describe 'Data Imports API', type: :request do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12, 'conversations' => 8 }) }
before do
account.enable_features!('data_import')
allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator)
end
describe 'POST /api/v1/accounts/:account_id/data_imports/validate_source' do
it 'validates the selected Intercom source and returns discovered totals' do
post validate_source_api_v1_account_data_imports_url(account_id: account.id),
params: {
source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[contacts conversations]
},
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to eq('valid' => true, 'totals' => { 'contacts' => 12, 'conversations' => 8 })
end
it 'returns a safe validation error' do
allow(validator).to receive(:perform).and_raise(DataImports::Intercom::Client::AuthenticationError, 'provider response')
post validate_source_api_v1_account_data_imports_url(account_id: account.id),
params: { source_provider: 'intercom', access_token: 'invalid', import_types: %w[contacts] },
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body).to eq(
'valid' => false,
'message' => 'We could not validate this Intercom access key. Check the key and its permissions.'
)
end
end
describe 'POST /api/v1/accounts/:account_id/data_imports' do
it 'returns unauthorized and does not enqueue imports when data import is disabled' do
account.disable_features!('data_import')
expect do
post api_v1_account_data_imports_url(account_id: account.id),
params: {
name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token',
import_types: %w[contacts conversations]
},
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unauthorized)
expect(account.data_imports).to be_empty
end
it 'creates and enqueues an Intercom import', :aggregate_failures do
expect do
post api_v1_account_data_imports_url(account_id: account.id),
params: {
name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token',
import_types: %w[contacts conversations]
},
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:ok)
data_import = account.data_imports.last
expect(data_import).to have_attributes(
name: 'Migration run',
data_type: 'intercom',
source_type: 'api',
source_provider: 'intercom',
initiated_by_id: admin.id
)
expect(data_import.access_token).to eq('intercom-token')
expect(data_import.import_types).to eq(%w[contacts conversations])
expect(data_import.stats).to include(
'contacts' => include('total' => 12),
'conversations' => include('total' => 8)
)
expect(response.parsed_body['source_provider']).to eq('intercom')
expect(response.parsed_body).not_to have_key('access_token')
end
it 'rejects creation while another Intercom import is active' do
active_import = create(
:data_import, :intercom,
account: account,
status: :processing
)
expect do
post api_v1_account_data_imports_url(account_id: account.id),
params: {
name: 'Second run', source_provider: 'intercom', access_token: 'intercom-token',
import_types: %w[contacts conversations]
},
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eq('Another data import is already in progress.')
expect(account.data_imports.where(data_type: 'intercom', source_provider: 'intercom').count).to eq(1)
expect(active_import.reload).to be_processing
end
it 'rejects unsupported import types instead of silently importing everything' do
allow(validator).to receive(:perform).and_raise(ArgumentError, 'Unsupported import types: companies')
expect do
post api_v1_account_data_imports_url(account_id: account.id),
params: {
name: 'Migration run', source_provider: 'intercom', access_token: 'intercom-token', import_types: %w[companies]
},
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eq('Unsupported import types: companies')
expect(account.data_imports).to be_empty
end
end
describe 'POST /api/v1/accounts/:account_id/data_imports/:id/start' do
let(:data_import) { create(:data_import, :intercom, account: account) }
it 'restarts abandoned imports' do
data_import.update!(
status: :abandoned,
abandoned_at: 1.hour.ago,
source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'previous-run' }
)
data_import.import_errors.create!(
error_code: 'StandardError',
message: 'old run error',
details: { kind: 'run_error' }
)
data_import.import_errors.create!(
error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE,
message: 'old skip log',
details: { kind: 'skipped' }
)
expect do
post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.to have_enqueued_job(DataImports::Intercom::ImportJob).with(data_import, a_kind_of(String))
expect(response).to have_http_status(:ok)
expect(data_import.reload).to be_pending
expect(data_import.abandoned_at).to be_nil
expect(data_import.started_at).to be_nil
expect(data_import.active_intercom_import_run_id).not_to eq('previous-run')
expect(data_import.import_errors.pluck(:error_code)).to eq([DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE])
end
it 'does not enqueue duplicate jobs for active imports' do
data_import.update!(status: :processing)
expect do
post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:ok)
expect(data_import.reload).to be_processing
end
it 'returns the active Intercom import instead of restarting another import' do
data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago)
active_import = create(
:data_import, :intercom,
account: account,
status: :processing
)
expect do
post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:ok)
expect(response.parsed_body['id']).to eq(active_import.id)
expect(data_import.reload).to be_abandoned
end
it 'does not restart imports when the stored access key is unavailable' do
data_import.update!(status: :abandoned, abandoned_at: 1.hour.ago, access_token: nil)
expect do
post start_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
end.not_to have_enqueued_job(DataImports::Intercom::ImportJob)
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['message']).to eq('The Intercom access key for this import is unavailable.')
expect(data_import.reload).to be_abandoned
end
end
describe 'POST /api/v1/accounts/:account_id/data_imports/:id/abandon' do
let(:data_import) { create(:data_import, :intercom, account: account) }
it 'abandons active imports' do
data_import.update!(status: :processing)
post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(data_import.reload).to be_abandoned
expect(data_import.abandoned_at).to be_present
end
it 'does not rewrite completed imports as abandoned' do
data_import.update!(status: :completed, completed_at: 1.hour.ago)
post abandon_api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(data_import.reload).to be_completed
expect(data_import.abandoned_at).to be_nil
end
it 'does not abandon legacy contact imports' do
legacy_import = create(:data_import, account: account, status: :processing)
post abandon_api_v1_account_data_import_url(account_id: account.id, id: legacy_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(legacy_import.reload).to be_processing
expect(legacy_import.abandoned_at).to be_nil
end
end
describe 'GET /api/v1/accounts/:account_id/data_imports/:id' do
let(:data_import) do
create(
:data_import, :intercom,
account: account,
name: 'July Intercom migration',
initiated_by: admin
)
end
it 'returns import details with recent errors' do
data_import.import_errors.create!(
error_code: 'Intercom::RateLimited',
message: 'Rate limited',
source_object_type: 'conversation',
source_object_id: 'conversation_1'
)
data_import.import_errors.create!(
error_code: 'DataImports::Intercom::SkippedMessage',
message: 'Skipped blank message',
source_object_type: 'message',
source_object_id: 'conversation:conversation_1:part:blank_part',
details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' }
)
get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body).to include(
'id' => data_import.id,
'name' => 'July Intercom migration',
'source_provider' => 'intercom',
'import_errors_count' => 1,
'skip_logs_count' => 1
)
expect(response.parsed_body['import_errors'].first).to include(
'error_code' => 'Intercom::RateLimited',
'message' => 'Rate limited',
'source_object_type' => 'conversation',
'source_object_id' => 'conversation_1'
)
expect(response.parsed_body['skip_logs'].first).to include(
'kind' => 'skipped',
'error_code' => 'DataImports::Intercom::SkippedMessage',
'source_object_type' => 'message',
'source_object_id' => 'conversation:conversation_1:part:blank_part'
)
end
it 'returns the latest five skip logs' do
16.times do |index|
data_import.import_errors.create!(
error_code: 'DataImports::Intercom::AlreadyImported',
message: 'Already imported in a previous import.',
source_object_type: 'message',
source_object_id: "message_#{index}",
details: { kind: 'skipped', reason: 'already_imported' },
created_at: Time.zone.at(index)
)
end
get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['skip_logs'].pluck('source_object_id')).to eq(%w[message_15 message_14 message_13 message_12 message_11])
expect(response.parsed_body).not_to have_key('skip_logs_pagination')
end
it 'filters skip logs by source object type with counts for each type' do
3.times do |index|
data_import.import_errors.create!(
error_code: 'DataImports::Intercom::AlreadyImported',
message: 'Already imported in a previous import.',
source_object_type: 'contact',
source_object_id: "contact_#{index}",
details: { kind: 'skipped', reason: 'already_imported' }
)
end
2.times do |index|
data_import.import_errors.create!(
error_code: 'DataImports::Intercom::AlreadyImported',
message: 'Already imported in a previous import.',
source_object_type: 'message',
source_object_id: "message_#{index}",
details: { kind: 'skipped', reason: 'already_imported' }
)
end
get api_v1_account_data_import_url(account_id: account.id, id: data_import.id, skip_logs_type: 'contact'),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['skip_logs'].pluck('source_object_type').uniq).to eq(['contact'])
expect(response.parsed_body['skip_logs_filters']).to include(
'selected_source_object_type' => 'contact',
'counts_by_type' => include('contact' => 3, 'message' => 2)
)
end
it 'returns the latest five error logs' do
16.times do |index|
data_import.import_errors.create!(
error_code: 'Intercom::RateLimited',
message: 'Rate limited',
source_object_type: 'conversation',
source_object_id: "conversation_#{index}",
created_at: Time.zone.at(index)
)
end
get api_v1_account_data_import_url(account_id: account.id, id: data_import.id),
headers: admin.create_new_auth_token,
as: :json
expect(response).to have_http_status(:ok)
expect(response.parsed_body['import_errors'].pluck('source_object_id')).to eq(
%w[conversation_15 conversation_14 conversation_13 conversation_12 conversation_11]
)
expect(response.parsed_body).not_to have_key('import_errors_pagination')
end
end
describe 'GET /api/v1/accounts/:account_id/data_imports/:id/error_logs.csv' do
let(:data_import) do
create(
:data_import, :intercom,
account: account,
initiated_by: admin
)
end
it 'downloads all error logs as CSV' do
6.times do |index|
data_import.import_errors.create!(
error_code: 'Intercom::RateLimited',
message: 'Rate limited',
source_object_type: 'conversation',
source_object_id: "conversation_#{index}",
details: { kind: 'run_error' }
)
end
get error_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv),
headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('text/csv')
expect(response.body).to include('source_object_type,source_object_id')
expect(response.body).to include('conversation,conversation_0,Intercom::RateLimited,Rate limited')
expect(response.body).to include('conversation,conversation_5,Intercom::RateLimited,Rate limited')
expect(response.body.lines.size).to eq(7)
end
end
describe 'GET /api/v1/accounts/:account_id/data_imports/:id/skip_logs.csv' do
let(:data_import) do
create(
:data_import, :intercom,
account: account,
initiated_by: admin
)
end
it 'downloads skip logs as CSV' do
data_import.import_errors.create!(
error_code: 'DataImports::Intercom::SkippedMessage',
message: 'Skipped blank message',
source_object_type: 'message',
source_object_id: 'conversation:conversation_1:part:blank_part',
details: { kind: 'skipped', reason: 'blank_or_unsupported_intercom_part' }
)
get skip_logs_api_v1_account_data_import_url(account_id: account.id, id: data_import.id, format: :csv),
headers: admin.create_new_auth_token
expect(response).to have_http_status(:ok)
expect(response.media_type).to eq('text/csv')
expect(response.body).to include('source_object_type,source_object_id')
expect(response.body).to include('message,conversation:conversation_1:part:blank_part')
end
end
end
@@ -0,0 +1,51 @@
require 'rails_helper'
event_content = {
'assignment' => 'Avery assigned the conversation to Support',
'assign_and_reopen' => 'Avery assigned the conversation to Support and reopened it',
'open' => 'Avery opened the conversation',
'close' => 'Avery closed the conversation',
'snoozed' => 'Avery snoozed the conversation',
'participant_added' => 'Avery added Support as a participant',
'participant_removed' => 'Avery removed Support as a participant',
'conversation_attribute_updated_by_admin' => 'Avery updated conversation attributes',
'conversation_attribute_updated_by_user' => 'Avery updated conversation attributes',
'conversation_attribute_updated_by_workflow' => 'Avery updated conversation attributes',
'ticket_attribute_updated_by_admin' => 'Avery updated ticket attributes',
'ticket_state_updated_by_admin' => 'Avery updated the ticket state',
'custom_action_started' => 'Avery started a custom action',
'custom_action_finished' => 'Avery finished a custom action',
'quick_reply' => 'Avery used a quick reply'
}.freeze
RSpec.describe DataImports::Intercom::ActivityContentBuilder do
event_content.each do |part_type, expected_content|
it "builds readable content for #{part_type}" do
part = {
'part_type' => part_type,
'author' => { 'type' => 'admin', 'name' => 'Avery' },
'assigned_to' => { 'name' => 'Support' }
}
expect(described_class.new(part).perform).to eq(expected_content)
end
end
it 'uses a humanized fallback for unknown future event types' do
part = { 'part_type' => 'journey_stage_changed', 'author' => { 'type' => 'bot' } }
expect(described_class.new(part).perform).to eq('Intercom automation recorded journey stage changed')
end
it 'appends sanitized body context' do
part = {
'part_type' => 'close',
'author' => { 'type' => 'admin' },
'body' => '<p>Customer confirmed <strong>resolution</strong></p><script>alert(1)</script>'
}
expect(described_class.new(part).perform).to eq(
'Intercom teammate closed the conversation: Customer confirmed resolution'
)
end
end
@@ -0,0 +1,16 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::Client do
let(:client) { described_class.new(access_token: 'intercom-token') }
describe '#list_contacts' do
it 'wraps transport failures in a retryable client error', :aggregate_failures do
allow(HTTParty).to receive(:get).and_raise(SocketError, 'getaddrinfo failed')
expect { client.list_contacts }.to raise_error(DataImports::Intercom::Client::Error) do |error|
expect(error.message).to eq('Intercom API request failed before receiving a response: getaddrinfo failed')
expect(error.body).to include(transport_error_class: 'SocketError')
end
end
end
end
@@ -0,0 +1,52 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::CreationService do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:validator) { instance_double(DataImports::Intercom::CredentialsValidator, perform: { 'contacts' => 12 }) }
before do
allow(DataImports::Intercom::CredentialsValidator).to receive(:new).and_return(validator)
end
it 'validates and creates an import with its credentials and totals', :aggregate_failures do
data_import = described_class.new(
account: account,
initiated_by: user,
source_params: {
name: 'Migration run',
source_provider: 'intercom',
access_token: ' intercom-token ',
import_types: %w[contacts]
}
).perform
expect(data_import).to have_attributes(
name: 'Migration run',
source_type: 'api',
source_provider: 'intercom',
import_types: %w[contacts],
access_token: 'intercom-token',
initiated_by_id: user.id
)
expect(data_import.stats.dig('contacts', 'total')).to eq(12)
expect(data_import.active_intercom_import_run_id).to be_present
end
it 'returns no import without validating when another import is active' do
create(:data_import, :intercom, account: account, status: :processing)
data_import = described_class.new(
account: account,
initiated_by: user,
source_params: {
name: 'Second run',
source_provider: 'intercom',
access_token: 'intercom-token'
}
).perform
expect(data_import).to be_nil
expect(validator).not_to have_received(:perform)
end
end
@@ -0,0 +1,54 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::CredentialsValidator do
let(:client) { instance_double(DataImports::Intercom::Client) }
before do
allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client)
allow(client).to receive(:list_contacts)
allow(client).to receive(:list_conversations)
end
it 'validates and counts only contacts when conversations are not selected' do
allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42)
totals = described_class.new(access_token: ' intercom-token ', import_types: %w[contacts]).perform
expect(totals).to eq('contacts' => 42)
expect(client).not_to have_received(:list_conversations)
end
it 'validates contact access and counts only conversations when contacts are not selected' do
allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 42)
allow(client).to receive(:list_conversations).with(per_page: 1).and_return('total_count' => 17)
totals = described_class.new(access_token: 'intercom-token', import_types: %w[conversations]).perform
expect(totals).to eq('conversations' => 17)
expect(client).to have_received(:list_contacts).with(per_page: 1)
end
it 'keeps an undiscovered total absent' do
allow(client).to receive(:list_contacts).with(per_page: 1).and_return('data' => [])
totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform
expect(totals).to be_empty
end
it 'preserves a known zero total' do
allow(client).to receive(:list_contacts).with(per_page: 1).and_return('total_count' => 0)
totals = described_class.new(access_token: 'intercom-token', import_types: %w[contacts]).perform
expect(totals).to eq('contacts' => 0)
end
it 'rejects an empty access key before calling Intercom' do
expect do
described_class.new(access_token: '', import_types: %w[contacts]).perform
end.to raise_error(ArgumentError, 'Intercom access key is required.')
expect(DataImports::Intercom::Client).not_to have_received(:new)
end
end
@@ -0,0 +1,968 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::Importer do
let(:account) { create(:account) }
let(:data_import) do
create(
:data_import, :intercom,
account: account
)
end
let(:client) { instance_double(DataImports::Intercom::Client) }
let(:contact_payload) do
{
'id' => 'contact_1',
'external_id' => 'external_1',
'email' => 'CUSTOMER@Example.com',
'phone' => '15551234567',
'name' => 'Customer One',
'created_at' => 1_700_000_000,
'updated_at' => 1_700_000_100
}
end
let(:conversation_payload) do
{
'id' => 'conversation_1',
'created_at' => 1_700_000_000,
'updated_at' => 1_700_000_200,
'state' => 'closed',
'open' => false,
'admin_assignee_id' => 123,
'team_assignee_id' => 456,
'contacts' => { 'contacts' => [{ 'id' => 'contact_1' }] },
'source' => {
'id' => 'source_1',
'type' => 'email',
'delivered_as' => 'customer_initiated',
'subject' => 'Need help',
'body' => '<p>Hello there</p>',
'author' => { 'type' => 'user', 'id' => 'contact_1', 'email' => 'CUSTOMER@example.com' }
},
'conversation_parts' => {
'conversation_parts' => [
{
'id' => 'part_1',
'part_type' => 'comment',
'body' => '<p>Admin reply</p>',
'created_at' => 1_700_000_100,
'updated_at' => 1_700_000_100,
'author' => { 'type' => 'admin', 'id' => 'admin_1' },
'attachments' => []
},
{
'id' => 'part_2',
'part_type' => 'note',
'body' => '<strong>Internal note</strong>',
'created_at' => 1_700_000_150,
'updated_at' => 1_700_000_150,
'author' => { 'type' => 'admin', 'id' => 'admin_1' },
'attachments' => []
}
]
}
}
end
before do
account.enable_features!('data_import')
allow(DataImports::Intercom::Client).to receive(:new).with(access_token: 'intercom-token').and_return(client)
allow(client).to receive(:list_contacts).with(starting_after: nil).and_return(
'data' => [contact_payload],
'total_count' => 1,
'pages' => { 'next' => nil }
)
allow(client).to receive(:list_conversations).with(starting_after: nil).and_return(
'conversations' => [{ 'id' => 'conversation_1' }],
'total_count' => 1,
'pages' => { 'next' => nil }
)
allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(conversation_payload)
allow(client).to receive(:retrieve_contact).with('contact_1').and_return(contact_payload)
end
it 'imports contacts, conversations, messages, and source-bucket inboxes without normal message creation callbacks', :aggregate_failures do
described_class.new(data_import: data_import).perform
contact = account.contacts.find_by!(email: 'customer@example.com')
expect(contact.name).to eq('Customer One')
expect(contact.phone_number).to eq('+15551234567')
expect(contact).to be_lead
expect(contact.custom_attributes).to include('intercom_contact_id' => 'contact_1')
inbox = account.inboxes.find_by!(name: 'Intercom Import - Email')
expect(inbox.channel.additional_attributes).to include('source_bucket' => 'email', 'import_placeholder' => true)
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
expect(conversation).to have_attributes(
status: 'resolved',
inbox_id: inbox.id,
contact_id: contact.id
)
expect(conversation.additional_attributes.dig('source', 'routing_method')).to eq('source_bucket_api_inbox')
expect(conversation.messages.order(:created_at).pluck(:content)).to eq(["Need help\n\nHello there", 'Admin reply', 'Internal note'])
expect(conversation.messages.order(:created_at).map(&:message_type)).to eq(%w[incoming outgoing outgoing])
expect(conversation.messages.order(:created_at).last.private).to be(true)
expect(data_import.reload).to be_completed
expect(data_import.stats).to include(
'contacts' => include('imported' => 1, 'skipped' => 0, 'total' => 1),
'conversations' => include('imported' => 1, 'skipped' => 0, 'total' => 1),
'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3),
'errors' => { 'count' => 0 }
)
expect(data_import.processed_records).to eq(5)
expect(data_import.items.imported.count).to eq(2)
expect(DataImportMapping.where(data_import: data_import).count).to eq(5)
end
it 'imports historical records without dispatching record events or outbound side effects', :aggregate_failures do
dispatched_events = []
allow(Rails.configuration.dispatcher).to receive(:dispatch) do |event_name, *_args|
dispatched_events << event_name
end
clear_enqueued_jobs
described_class.new(data_import: data_import).perform
record_events = [
Events::Types::CONTACT_CREATED,
Events::Types::CONTACT_UPDATED,
Events::Types::CONVERSATION_CREATED,
Events::Types::CONVERSATION_UPDATED,
Events::Types::CONVERSATION_STATUS_CHANGED,
Events::Types::ASSIGNEE_CHANGED,
Events::Types::TEAM_CHANGED,
Events::Types::MESSAGE_CREATED,
Events::Types::FIRST_REPLY_CREATED,
Events::Types::REPLY_CREATED
]
side_effect_jobs = [SendReplyJob, EventDispatcherJob, ActionCableBroadcastJob, WebhookJob, HookJob]
expect(dispatched_events & record_events).to be_empty
expect(enqueued_jobs.pluck(:job) & side_effect_jobs).to be_empty
expect(Notification.where(account: account)).to be_empty
end
context 'when Intercom contact activity timestamps are available' do
let(:contact_payload) do
super().merge('last_seen_at' => 1_700_000_050, 'last_replied_at' => 1_700_000_090)
end
it 'prefers last_seen_at for contact activity' do
described_class.new(data_import: data_import).import_contacts_page
contact = account.contacts.find_by!(email: 'customer@example.com')
expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_050))
end
end
context 'when Intercom contact last_seen_at is unavailable' do
let(:contact_payload) do
super().merge('last_seen_at' => nil, 'last_replied_at' => 1_700_000_090)
end
it 'falls back to last_replied_at for contact activity' do
described_class.new(data_import: data_import).import_contacts_page
contact = account.contacts.find_by!(email: 'customer@example.com')
expect(contact.last_activity_at).to eq(Time.zone.at(1_700_000_090))
end
end
it 'leaves contact activity blank when Intercom activity timestamps are unavailable' do
described_class.new(data_import: data_import).import_contacts_page
contact = account.contacts.find_by!(email: 'customer@example.com')
expect(contact.last_activity_at).to be_nil
end
it 'updates message totals by delta when a conversation page is retried' do
importer = described_class.new(data_import: data_import)
importer.import_conversations_page
importer.import_conversations_page
expect(data_import.reload.stats.dig('messages', 'total')).to eq(3)
item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
expect(item.metadata['message_total_contribution']).to eq(3)
end
it 'reconciles imported message stats from same-run mappings on retry' do
described_class.new(data_import: data_import).import_conversations_page
stats = data_import.reload.stats.deep_dup
stats['messages']['imported'] = 0
data_import.update!(stats: stats)
described_class.new(data_import: data_import).import_conversations_page
expect(data_import.reload.stats.dig('messages', 'imported')).to eq(3)
end
it 'indexes imported messages for advanced search' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
reindexed_message_ids = []
original_reindex_for_search = Message.instance_method(:reindex_for_search)
Message.define_method(:reindex_for_search) { reindexed_message_ids << id }
Message.__send__(:private, :reindex_for_search)
described_class.new(data_import: data_import).perform
expect(reindexed_message_ids).to match_array(Message.where(account_id: account.id).pluck(:id))
ensure
Message.define_method(:reindex_for_search, original_reindex_for_search)
Message.__send__(:private, :reindex_for_search)
end
it 'keeps imported messages successful when search reindexing fails', :aggregate_failures do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(Message).to receive(:reindex_for_search).and_raise(StandardError, 'search unavailable')
# rubocop:enable RSpec/AnyInstance
described_class.new(data_import: data_import).perform
message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1')
mapping = data_import.mappings.find_by!(source_object_type: 'message', source_object_id: 'conversation:conversation_1:source:source_1')
expect(mapping.chatwoot_record).to eq(message)
expect(data_import.reload).to be_completed
expect(data_import.import_errors.exists?).to be(false)
expect(data_import.stats.dig('messages', 'imported')).to eq(3)
end
describe '#start!' do
it 'does not overwrite an import abandoned by another process', :aggregate_failures do
importer = described_class.new(data_import: data_import)
DataImport.find(data_import.id).update!(
status: :abandoned,
abandoned_at: Time.current
)
expect(importer.start!).to be_nil
expect(data_import.reload).to be_abandoned
expect(data_import.started_at).to be_nil
end
end
describe '#perform' do
it 'stops when the import was abandoned before processing starts' do
importer = described_class.new(data_import: data_import)
DataImport.find(data_import.id).update!(
status: :abandoned,
abandoned_at: Time.current
)
expect(client).not_to receive(:list_contacts)
importer.perform
expect(data_import.reload).to be_abandoned
end
end
describe '#import_conversations_page' do
it 'stops an in-flight page when a newer import run takes over', :aggregate_failures do
run_id = 'intercom-run-1'
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => run_id })
allow(client).to receive(:list_conversations).with(starting_after: nil).and_return(
'conversations' => [{ 'id' => 'conversation_1' }, { 'id' => 'conversation_2' }],
'pages' => { 'next' => { 'starting_after' => 'next-conversation-cursor' } }
)
allow(client).to receive(:retrieve_conversation).with('conversation_1') do
data_import.update!(source_metadata: { DataImport::ACTIVE_INTERCOM_IMPORT_RUN_ID_KEY => 'new-run' })
conversation_payload
end
result = described_class.new(data_import: data_import, run_id: run_id).import_conversations_page
expect(result).to be_done
expect(client).not_to have_received(:retrieve_conversation).with('conversation_2')
expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty
expect(account.contacts.where(email: 'customer@example.com')).to be_empty
expect(data_import.reload.cursor.dig('conversations', 'starting_after')).to be_nil
end
it 'rolls back a newly inserted conversation when mapping persistence fails', :aggregate_failures do
importer = described_class.new(data_import: data_import)
allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
raise StandardError, 'mapping failed' if object_type == 'conversation'
method.call(object_type, source_id, record, metadata: metadata)
end
importer.import_conversations_page
expect(account.conversations.where(identifier: 'intercom:conversation_1')).to be_empty
item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
expect(item).to be_failed
expect(item.last_error_message).to eq('mapping failed')
end
it 'rolls back a newly inserted contact when mapping persistence fails', :aggregate_failures do
sparse_contact = contact_payload.slice('id', 'name', 'created_at', 'updated_at')
allow(client).to receive(:retrieve_contact).with('contact_1').and_return(sparse_contact)
importer = described_class.new(data_import: data_import)
allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
raise StandardError, 'mapping failed' if object_type == 'contact'
method.call(object_type, source_id, record, metadata: metadata)
end
importer.import_conversations_page
expect(account.contacts.where(name: 'Customer One')).to be_empty
expect(data_import.mappings.where(source_object_type: 'contact', source_object_id: 'contact_1')).to be_empty
contact_item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(contact_item).to be_failed
expect(contact_item.last_error_message).to eq('mapping failed')
end
it 'rolls back a newly inserted message when mapping persistence fails', :aggregate_failures do
importer = described_class.new(data_import: data_import)
allow(importer).to receive(:record_mapping).and_wrap_original do |method, object_type, source_id, record, metadata:|
raise StandardError, 'mapping failed' if object_type == 'message'
method.call(object_type, source_id, record, metadata: metadata)
end
importer.import_conversations_page
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
expect(conversation.messages.where(source_id: 'intercom:conversation:conversation_1:source:source_1')).to be_empty
error = data_import.import_errors.find_by!(
source_object_type: 'message',
source_object_id: 'conversation:conversation_1:source:source_1'
)
expect(error).to have_attributes(error_code: 'StandardError', message: 'mapping failed')
end
end
describe '#finish!' do
it 'does not overwrite an import abandoned by another process' do
data_import.update!(status: :processing)
importer = described_class.new(data_import: data_import)
DataImport.find(data_import.id).update!(
status: :abandoned,
abandoned_at: Time.current
)
importer.finish!
expect(data_import.reload).to be_abandoned
expect(data_import.completed_at).to be_nil
end
end
describe '#fail!' do
it 'does not overwrite an import abandoned by another process', :aggregate_failures do
data_import.update!(status: :processing)
importer = described_class.new(data_import: data_import)
DataImport.find(data_import.id).update!(
status: :abandoned,
abandoned_at: Time.current
)
importer.fail!(StandardError.new('boom'))
expect(data_import.reload).to be_abandoned
expect(data_import.last_error_at).to be_nil
expect(data_import.import_errors.exists?).to be(false)
end
end
context 'when the Intercom records were imported by an earlier run' do
let(:next_data_import) do
create(
:data_import, :intercom,
account: account
)
end
it 'records the already mapped records as skipped for the current import run', :aggregate_failures do
described_class.new(data_import: data_import).perform
described_class.new(data_import: next_data_import).perform
expect(next_data_import.reload.stats).to include(
'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
'messages' => include('imported' => 0, 'skipped' => 3, 'total' => 3),
'errors' => { 'count' => 0 }
)
expect(next_data_import).to be_completed
expect(next_data_import.total_records).to eq(5)
expect(next_data_import.processed_records).to eq(0)
expect(next_data_import.items.skipped.count).to eq(2)
expect(next_data_import.import_errors.skip_logs.group(:source_object_type).count).to eq(
'contact' => 1,
'conversation' => 1,
'message' => 3
)
expect(next_data_import.import_errors.skip_logs.pluck(:details).map { |details| details['reason'] }.uniq).to eq(['already_imported'])
end
it 'recreates messages when existing message mappings point to deleted records', :aggregate_failures do
described_class.new(data_import: data_import).perform
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
Message.where(conversation_id: conversation.id).delete_all
described_class.new(data_import: next_data_import).perform
expect(conversation.reload.messages.pluck(:source_id)).to match_array(
%w[
intercom:conversation:conversation_1:source:source_1
intercom:conversation:conversation_1:part:part_1
intercom:conversation:conversation_1:part:part_2
]
)
expect(next_data_import.reload.stats).to include(
'contacts' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
'conversations' => include('imported' => 0, 'skipped' => 1, 'total' => 1),
'messages' => include('imported' => 3, 'skipped' => 0, 'total' => 3),
'errors' => { 'count' => 0 }
)
expect(next_data_import.import_errors.skip_logs.where(source_object_type: 'message')).to be_empty
message_mappings = DataImportMapping.where(account: account, source_provider: 'intercom', source_object_type: 'message')
expect(message_mappings.filter_map(&:chatwoot_record).count).to eq(3)
end
it 'updates conversation activity when a later import adds new messages to the mapped conversation', :aggregate_failures do
new_part = {
'id' => 'part_3',
'part_type' => 'comment',
'body' => '<p>Follow-up reply</p>',
'created_at' => 1_700_000_300,
'updated_at' => 1_700_000_300,
'author' => { 'type' => 'admin', 'id' => 'admin_1' },
'attachments' => []
}
updated_conversation_payload = conversation_payload.deep_dup
updated_conversation_payload['updated_at'] = 1_700_000_300
updated_conversation_payload['conversation_parts']['conversation_parts'] << new_part
allow(client).to receive(:retrieve_conversation).with('conversation_1').and_return(
conversation_payload,
updated_conversation_payload
)
described_class.new(data_import: data_import).perform
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
described_class.new(data_import: next_data_import).perform
expect(conversation.reload.last_activity_at).to eq(Time.zone.at(1_700_000_300))
expect(conversation.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:part_3').content).to eq('Follow-up reply')
end
end
context 'when a conversation references an already mapped contact' do
it 'reuses the mapped contact without hydrating the sparse reference' do
described_class.new(data_import: data_import).import_contacts_page
expect(client).not_to receive(:retrieve_contact)
described_class.new(data_import: data_import).import_conversations_page
end
end
context 'when a same-run contact mapping outlives its item progress' do
let!(:mapped_contact) { create(:contact, account: account) }
before do
DataImportMapping.create!(
account: account,
data_import: data_import,
source_provider: 'intercom',
source_object_type: 'contact',
source_object_id: 'contact_1',
chatwoot_record_type: 'Contact',
chatwoot_record_id: mapped_contact.id,
metadata: {}
)
data_import.items.create!(
source_provider: 'intercom',
source_object_type: 'contact',
source_object_id: 'contact_1',
status: :processing,
metadata: contact_payload
)
end
it 'repairs the item and imported count on retry', :aggregate_failures do
described_class.new(data_import: data_import).import_contacts_page
item = data_import.items.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(item).to be_imported
expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: mapped_contact.id)
expect(data_import.reload.stats.dig('contacts', 'imported')).to eq(1)
end
end
context 'when an existing contact has the same email but a different external id' do
let(:contact_payload) do
super().merge('last_replied_at' => 1_700_000_090)
end
let!(:existing_contact) { create(:contact, account: account, email: 'customer@example.com', identifier: nil) }
it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do
described_class.new(data_import: data_import).import_contacts_page
expect(existing_contact.reload.identifier).to eq('external_1')
expect(existing_contact.last_activity_at).to eq(Time.zone.at(1_700_000_090))
expect(account.contacts.where(email: 'customer@example.com').count).to eq(1)
item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
end
end
context 'when an existing contact has the same phone but a different external id' do
let(:contact_payload) do
super().merge('email' => nil)
end
let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) }
it 'updates the existing contact instead of creating a duplicate', :aggregate_failures do
described_class.new(data_import: data_import).import_contacts_page
expect(existing_contact.reload.identifier).to eq('external_1')
expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
end
end
context 'when an existing contact has the same phone but Intercom sends a new email' do
let!(:existing_contact) { create(:contact, account: account, phone_number: '+15551234567', identifier: nil) }
it 'falls through to the phone match after the email lookup misses', :aggregate_failures do
described_class.new(data_import: data_import).import_contacts_page
expect(existing_contact.reload.email).to eq('customer@example.com')
expect(existing_contact.identifier).to eq('external_1')
expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
end
end
context 'when an existing visitor contact matches the Intercom external id' do
let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') }
it 'promotes the contact to a lead when adding email or phone', :aggregate_failures do
expect(existing_contact).to be_visitor
described_class.new(data_import: data_import).import_contacts_page
expect(existing_contact.reload).to be_lead
expect(existing_contact.email).to eq('customer@example.com')
expect(existing_contact.phone_number).to eq('+15551234567')
end
end
context 'when an identifier match has contact details owned by another contact' do
let!(:existing_contact) { create(:contact, account: account, identifier: 'external_1') }
let!(:email_owner) { create(:contact, account: account, email: 'customer@example.com') }
let!(:phone_owner) { create(:contact, account: account, phone_number: '+15551234567') }
it 'does not copy the conflicting email or phone number', :aggregate_failures do
described_class.new(data_import: data_import).import_contacts_page
expect(existing_contact.reload.email).to be_nil
expect(existing_contact.phone_number).to be_nil
expect(existing_contact).to be_visitor
expect(email_owner.reload.email).to eq('customer@example.com')
expect(phone_owner.reload.phone_number).to eq('+15551234567')
expect(account.contacts.where(email: 'customer@example.com').count).to eq(1)
expect(account.contacts.where(phone_number: '+15551234567').count).to eq(1)
item = data_import.items.imported.find_by!(source_object_type: 'contact', source_object_id: 'contact_1')
expect(item).to have_attributes(chatwoot_record_type: 'Contact', chatwoot_record_id: existing_contact.id)
end
end
context 'when Intercom rate limits a conversation detail request' do
before do
allow(client).to receive(:retrieve_conversation).with('conversation_1').and_raise(
DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429)
)
end
it 're-raises the provider error so the page job can retry', :aggregate_failures do
expect { described_class.new(data_import: data_import).import_conversations_page }
.to raise_error(DataImports::Intercom::Client::RateLimitError)
item = data_import.items.find_by!(source_object_type: 'conversation', source_object_id: 'conversation_1')
expect(item).to be_processing
expect(data_import.import_errors.exists?).to be(false)
end
end
context 'when Intercom rate limits a contact hydration request' do
before do
allow(client).to receive(:retrieve_contact).with('contact_1').and_raise(
DataImports::Intercom::Client::RateLimitError.new('rate limited', status: 429)
)
end
it 're-raises the provider error instead of importing a sparse contact', :aggregate_failures do
expect { described_class.new(data_import: data_import).import_conversations_page }
.to raise_error(DataImports::Intercom::Client::RateLimitError)
expect(data_import.items.exists?(source_object_type: 'contact')).to be(false)
expect(data_import.import_errors.exists?).to be(false)
end
end
context 'when Intercom no longer has a sparse contact referenced by a conversation' do
before do
allow(client).to receive(:retrieve_contact).with('contact_1').and_raise(
DataImports::Intercom::Client::Error.new('not found', status: 404)
)
end
it 'falls back to the conversation contact reference', :aggregate_failures do
expect { described_class.new(data_import: data_import).import_conversations_page }.not_to raise_error
expect(data_import.items.imported.exists?(source_object_type: 'contact', source_object_id: 'contact_1')).to be(true)
expect(data_import.import_errors.exists?).to be(false)
end
end
context 'when the Intercom source message only has attachments' do
let(:conversation_payload) do
super().deep_merge(
'source' => {
'subject' => nil,
'body' => nil,
'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
},
'conversation_parts' => {
'conversation_parts' => []
}
)
end
it 'imports the source message attachment placeholder', :aggregate_failures do
described_class.new(data_import: data_import).perform
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
expect(conversation.messages.pluck(:content)).to eq(['[Intercom attachment skipped: 1]'])
expect(conversation.messages.first.additional_attributes.dig('source', 'attachments')).to eq(
[{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
)
expect(data_import.reload.stats.dig('messages', 'imported')).to eq(1)
end
end
context 'when the Intercom source message has text and attachments' do
let(:conversation_payload) do
super().deep_merge(
'source' => {
'attachments' => [{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
}
)
end
it 'adds an attachment omission marker to the imported message', :aggregate_failures do
described_class.new(data_import: data_import).perform
message = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:source:source_1')
expect(message.content).to eq("Need help\n\nHello there\n\n[Intercom attachment skipped: 1]")
expect(message.additional_attributes.dig('source', 'attachments')).to eq(
[{ 'name' => 'invoice.pdf', 'url' => 'https://example.com/invoice.pdf' }]
)
expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(0)
end
end
context 'when Intercom omits the conversation source' do
let(:conversation_payload) do
super().merge(
'source' => nil,
'first_contact_reply' => {
'type' => 'whatsapp',
'created_at' => 1_700_000_000,
'url' => nil
}
)
end
it 'routes the conversation from the first contact reply type', :aggregate_failures do
described_class.new(data_import: data_import).perform
inbox = account.inboxes.find_by!(name: 'Intercom Import - WhatsApp')
conversation = account.conversations.find_by!(identifier: 'intercom:conversation_1')
expect(conversation.inbox).to eq(inbox)
expect(conversation.additional_attributes.dig('source', 'source_type')).to eq('whatsapp')
end
end
context 'when an Intercom chat message part cannot be imported' do
let(:conversation_payload) do
super().deep_merge(
'conversation_parts' => {
'conversation_parts' => [
{
'id' => 'blank_part',
'part_type' => 'comment',
'body' => nil,
'created_at' => 1_700_000_175,
'updated_at' => 1_700_000_175,
'author' => { 'type' => 'admin', 'id' => 'admin_1' },
'attachments' => []
}
]
}
)
end
it 'records a skip log with the Intercom message source id', :aggregate_failures do
described_class.new(data_import: data_import).perform
skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message')
expect(skip_log).to have_attributes(
source_object_id: 'conversation:conversation_1:part:blank_part',
error_code: 'DataImports::Intercom::SkippedMessage',
message: 'Skipped Intercom comment event blank_part: no message body or attachments to import.'
)
expect(skip_log.details).to include(
'kind' => 'skipped',
'reason' => 'blank_or_unsupported_intercom_part',
'reason_details' => 'no message body or attachments to import',
'event_name' => 'comment',
'event_type' => 'comment',
'author_type' => 'admin'
)
expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1)
end
it 'records the skip log again for a later import run', :aggregate_failures do
described_class.new(data_import: data_import).perform
next_data_import = create(
:data_import, :intercom,
account: account
)
described_class.new(data_import: next_data_import).perform
skip_log = next_data_import.import_errors.skip_logs.find_by!(
source_object_type: 'message',
source_object_id: 'conversation:conversation_1:part:blank_part',
error_code: 'DataImports::Intercom::SkippedMessage'
)
expect(skip_log).to have_attributes(
source_object_id: 'conversation:conversation_1:part:blank_part',
error_code: 'DataImports::Intercom::SkippedMessage'
)
expect(next_data_import.reload.stats.dig('messages', 'skipped')).to eq(2)
end
it 'reconciles a same-run skipped mapping and missing skip log on retry', :aggregate_failures do
described_class.new(data_import: data_import).import_conversations_page
data_import.import_errors.where(source_object_type: 'message').delete_all
stats = data_import.reload.stats.deep_dup
stats['messages']['skipped'] = 0
data_import.update!(stats: stats)
described_class.new(data_import: data_import).import_conversations_page
expect(data_import.reload.stats.dig('messages', 'skipped')).to eq(1)
expect(data_import.import_errors.skip_logs.exists?(source_object_id: 'conversation:conversation_1:part:blank_part')).to be(true)
end
it 'repairs a previously skipped mapping when the part is now an activity', :aggregate_failures do
described_class.new(data_import: data_import).perform
previous_skip_log = data_import.import_errors.skip_logs.find_by!(source_object_id: 'conversation:conversation_1:part:blank_part')
conversation_payload.dig('conversation_parts', 'conversation_parts').first.merge!(
'part_type' => 'assignment',
'assigned_to' => { 'name' => 'Support' }
)
next_data_import = create(:data_import, :intercom, account: account)
described_class.new(data_import: next_data_import).perform
activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:blank_part')
mapping = DataImportMapping.find_by!(
account: account,
source_provider: 'intercom',
source_object_type: 'message',
source_object_id: 'conversation:conversation_1:part:blank_part'
)
expect(activity).to be_activity
expect(activity.content).to eq('Intercom teammate assigned the conversation to Support')
expect(mapping.chatwoot_record).to eq(activity)
expect(data_import.import_errors.skip_logs).to include(previous_skip_log)
expect(next_data_import.import_errors.skip_logs.where(source_object_id: mapping.source_object_id)).to be_empty
end
end
context 'when Intercom returns bodyless lifecycle events' do
let(:conversation_payload) do
super().deep_merge(
'conversation_parts' => {
'total_count' => 1,
'conversation_parts' => [
{
'id' => 'assignment_part',
'part_type' => 'assignment',
'body' => nil,
'created_at' => 1_700_000_175,
'author' => { 'type' => 'admin', 'name' => 'Avery' },
'assigned_to' => { 'type' => 'team', 'name' => 'Support' },
'state' => 'open',
'tags' => { 'tags' => [{ 'name' => 'priority' }] },
'event_details' => { 'source' => 'workflow' },
'app_package_code' => 'workflow'
}
]
}
)
end
it 'imports events as public activity messages with source metadata', :aggregate_failures do
described_class.new(data_import: data_import).perform
activity = account.messages.find_by!(source_id: 'intercom:conversation:conversation_1:part:assignment_part')
expect(activity).to have_attributes(
message_type: 'activity',
content: 'Avery assigned the conversation to Support',
private: false,
sender: nil,
created_at: Time.zone.at(1_700_000_175)
)
expect(activity.additional_attributes['source']).to include(
'part_type' => 'assignment',
'assigned_to' => include('name' => 'Support'),
'state' => 'open',
'event_details' => include('source' => 'workflow'),
'app_package_code' => 'workflow'
)
expect(data_import.reload.stats['messages']).to include('imported' => 2, 'skipped' => 0, 'total' => 2)
expect(data_import.import_errors.skip_logs).to be_empty
end
end
context 'when Intercom omits older conversation parts from the retrieved conversation' do
let(:conversation_payload) do
super().deep_merge(
'conversation_parts' => {
'total_count' => 503
},
'statistics' => {
'count_conversation_parts' => 503
}
)
end
it 'records an incomplete import error and completes with errors', :aggregate_failures do
described_class.new(data_import: data_import).perform
error = data_import.import_errors.non_skip_logs.find_by!(
source_object_type: 'conversation',
source_object_id: 'conversation_1',
error_code: 'DataImports::Intercom::TruncatedConversationParts'
)
expect(error.message).to eq('Intercom returned 2 of 503 conversation parts.')
expect(error.details).to include(
'kind' => 'incomplete',
'imported_parts_count' => 2,
'total_parts_count' => 503
)
expect(data_import.reload).to be_completed_with_errors
expect(data_import.stats.dig('errors', 'count')).to eq(1)
end
end
context 'when the conversation parts total matches the returned parts' do
let(:conversation_payload) do
super().deep_merge(
'conversation_parts' => {
'total_count' => 2
},
'statistics' => {
'count_conversation_parts' => 2
}
)
end
it 'does not record a truncated parts error', :aggregate_failures do
described_class.new(data_import: data_import).perform
expect(data_import.import_errors.non_skip_logs).to be_empty
expect(data_import.reload).to be_completed
expect(data_import.stats.dig('errors', 'count')).to eq(0)
end
end
context 'when Intercom statistics count is higher than the conversation parts total' do
let(:conversation_payload) do
super().deep_merge(
'source' => {},
'conversation_parts' => {
'total_count' => 2
},
'statistics' => {
'count_conversation_parts' => 3
}
)
end
it 'trusts the returned conversation parts total over the statistics counter', :aggregate_failures do
described_class.new(data_import: data_import).perform
expect(data_import.import_errors.non_skip_logs).to be_empty
expect(data_import.reload).to be_completed
expect(data_import.stats.dig('errors', 'count')).to eq(0)
end
end
context 'when a specific Intercom message part fails to persist' do
let(:conversation_payload) do
super().deep_merge(
'conversation_parts' => {
'conversation_parts' => [
{
'id' => 'bad_part',
'part_type' => 'comment',
'body' => '<p>Message that cannot be stored</p>',
'created_at' => 1_700_000_175,
'updated_at' => 1_700_000_175,
'author' => { 'type' => 'admin', 'id' => 'admin_1' },
'attachments' => []
}
]
}
)
end
before do
allow(Message).to receive(:insert_all!).and_wrap_original do |method, records, **kwargs|
raise ActiveRecord::StatementInvalid, 'bad message' if records.first[:source_id] == 'intercom:conversation:conversation_1:part:bad_part'
method.call(records, **kwargs)
end
end
it 'records a skip log with the Intercom message part id', :aggregate_failures do
described_class.new(data_import: data_import).perform
skip_log = data_import.import_errors.skip_logs.find_by!(source_object_type: 'message')
expect(skip_log).to have_attributes(
source_object_id: 'conversation:conversation_1:part:bad_part',
error_code: 'ActiveRecord::StatementInvalid',
message: 'bad message'
)
expect(skip_log.details).to include(
'kind' => 'failed',
'conversation_id' => 'intercom:conversation_1'
)
expect(data_import.reload).to be_completed_with_errors
expect(data_import.stats.dig('errors', 'count')).to eq(1)
end
end
end
@@ -0,0 +1,33 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::PlaceholderInboxBuilder do
let(:account) { create(:account) }
describe '#inbox_for' do
it 'creates a source-bucket API inbox for an Intercom conversation source' do
inbox = described_class.new(account: account).inbox_for('email')
expect(inbox.name).to eq('Intercom Import - Email')
expect(inbox.channel).to be_a(Channel::Api)
expect(inbox.enable_auto_assignment).to be(false)
expect(inbox.allow_messages_after_resolved).to be(false)
expect(inbox.channel.additional_attributes).to include(
'source_provider' => 'intercom',
'source_bucket' => 'email',
'import_placeholder' => true,
'agent_reply_time_window' => 1
)
end
it 'reuses an existing placeholder inbox for the same source bucket' do
builder = described_class.new(account: account)
first_inbox = builder.inbox_for('phone_call')
expect(account).not_to receive(:inboxes)
second_inbox = builder.inbox_for('phone_switch')
expect(second_inbox).to eq(first_inbox)
expect(Inbox.where(account: account, channel_type: 'Channel::Api').count).to eq(1)
end
end
end
@@ -0,0 +1,64 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::RestartService do
let(:account) { create(:account) }
let(:data_import) { create(:data_import, :intercom, account: account, status: :abandoned, abandoned_at: 1.hour.ago) }
it 'prepares a failed or abandoned import for another run', :aggregate_failures do
data_import.update!(
stats: {
'contacts' => { 'imported' => 1, 'skipped' => 9, 'total' => 10 },
'conversations' => { 'imported' => 2, 'skipped' => 8, 'total' => 10 },
'messages' => { 'imported' => 3, 'skipped' => 7, 'total' => 10 },
'errors' => { 'count' => 6 }
}
)
data_import.import_errors.create!(error_code: 'StandardError', message: 'old run error')
data_import.import_errors.create!(
error_code: 'ContactFailed',
message: 'old contact error',
source_object_type: 'contact',
details: { kind: 'failed' }
)
retained_skip_log = data_import.import_errors.create!(
error_code: DataImports::Intercom::Importer::ALREADY_IMPORTED_ERROR_CODE,
message: 'old skip log',
source_object_type: 'contact',
details: { kind: 'skipped' }
)
previous_run_id = data_import.assign_active_intercom_import_run_id
data_import.save!
service = described_class.new(account: account, data_import: data_import)
expect(service.perform).to eq(:enqueue)
expect(service.data_import).to be_pending
expect(service.data_import.abandoned_at).to be_nil
expect(service.data_import.started_at).to be_nil
expect(service.data_import.active_intercom_import_run_id).not_to eq(previous_run_id)
expect(service.data_import.import_errors).to contain_exactly(retained_skip_log)
expect(service.data_import.stats).to eq(
'contacts' => { 'imported' => 1, 'skipped' => 1, 'total' => 10 },
'conversations' => { 'imported' => 2, 'skipped' => 0, 'total' => 10 },
'messages' => { 'imported' => 3, 'skipped' => 0, 'total' => 10 },
'errors' => { 'count' => 0 }
)
end
it 'returns the active import instead of restarting another import', :aggregate_failures do
active_import = create(:data_import, :intercom, account: account, status: :processing)
service = described_class.new(account: account, data_import: data_import)
expect(service.perform).to eq(:render_show)
expect(service.data_import).to eq(active_import)
expect(data_import.reload).to be_abandoned
end
it 'does not restart when the stored access token is missing' do
data_import.update!(access_token: nil)
result = described_class.new(account: account, data_import: data_import).perform
expect(result).to eq(:access_token_missing)
expect(data_import.reload).to be_abandoned
end
end
@@ -0,0 +1,17 @@
require 'rails_helper'
RSpec.describe DataImports::Intercom::SourceBucket do
describe '.for' do
it 'maps Intercom source types to Chatwoot inbox buckets' do
expect(described_class.for('email')).to eq({ key: 'email', name: 'Email' })
expect(described_class.for('phone_switch')).to eq({ key: 'phone', name: 'Phone' })
expect(described_class.for('inapp')).to eq({ key: 'messenger', name: 'Messenger' })
expect(described_class.for('messenger')).to eq({ key: 'messenger', name: 'Messenger' })
expect(described_class.for('push')).to eq({ key: 'messenger', name: 'Messenger' })
end
it 'uses an unknown bucket for unsupported source types' do
expect(described_class.for('unsupported_source')).to eq({ key: 'unknown', name: 'Unknown' })
end
end
end
@@ -51,18 +51,41 @@ RSpec.describe Whatsapp::WebhookTeardownService do
end
end
context 'when channel is whatsapp_cloud but not embedded_signup' do
context 'when channel is whatsapp_cloud with manual setup' do
before do
allow(channel).to receive(:setup_webhooks).and_return(true)
channel.update!(
provider: 'whatsapp_cloud',
provider_config: { 'source' => 'manual' }
provider_config: {
'source' => 'manual',
'phone_number_id' => 'manual_phone_id',
'business_account_id' => 'manual_waba_id',
'api_key' => 'manual_api_key'
}
)
end
it 'does not attempt to unsubscribe webhook' do
expect(Whatsapp::FacebookApiClient).not_to receive(:new)
it 'clears the phone number callback override' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).with('manual_api_key').and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override).with('manual_phone_id')
service.perform
expect(api_client).to have_received(:clear_phone_number_callback_override).with('manual_phone_id')
end
# The manual token belongs to the customer's own Meta app, so its WABA subscription is not ours to remove.
it 'does not unsubscribe the app from the WABA' do
api_client = instance_double(Whatsapp::FacebookApiClient)
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
allow(api_client).to receive(:clear_phone_number_callback_override)
allow(api_client).to receive(:unsubscribe_app_from_waba)
service.perform
expect(api_client).not_to have_received(:unsubscribe_app_from_waba)
end
end