Merge remote-tracking branch 'origin/assignment_v2/assignment_service' into assignment_v2/assignment_service
This commit is contained in:
@@ -118,6 +118,45 @@ RSpec.describe AutomationRules::ActionService do
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with add_label action' do
|
||||
before do
|
||||
rule.actions << { action_name: 'add_label', action_params: %w[bug feature] }
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will add labels to conversation' do
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list).to include('bug', 'feature')
|
||||
end
|
||||
|
||||
it 'will not duplicate existing labels' do
|
||||
conversation.add_labels(['bug'])
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list.count('bug')).to eq(1)
|
||||
expect(conversation.reload.label_list).to include('feature')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with remove_label action' do
|
||||
before do
|
||||
conversation.add_labels(%w[bug feature support])
|
||||
rule.actions << { action_name: 'remove_label', action_params: %w[bug feature] }
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will remove specified labels from conversation' do
|
||||
described_class.new(rule, account, conversation).perform
|
||||
expect(conversation.reload.label_list).not_to include('bug', 'feature')
|
||||
expect(conversation.reload.label_list).to include('support')
|
||||
end
|
||||
|
||||
it 'will not fail if labels do not exist on conversation' do
|
||||
conversation.update_labels(['support']) # Remove bug and feature first
|
||||
expect { described_class.new(rule, account, conversation).perform }.not_to raise_error
|
||||
expect(conversation.reload.label_list).to include('support')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#perform with add_private_note action' do
|
||||
let(:message_builder) { double }
|
||||
|
||||
|
||||
@@ -134,5 +134,86 @@ RSpec.describe AutomationRules::ConditionsFilterService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when conditions based on labels' do
|
||||
before do
|
||||
conversation.add_labels(['bug'])
|
||||
end
|
||||
|
||||
context 'when filter_operator is equal_to' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['bug'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation has the label' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation does not have the label' do
|
||||
rule.conditions = [
|
||||
{ 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'equal_to' }
|
||||
]
|
||||
rule.save
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is not_equal_to' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': ['feature'], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'not_equal_to' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation does not have the label' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation has the label' do
|
||||
conversation.add_labels(['feature'])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is is_present' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_present' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return true when conversation has any labels' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
|
||||
it 'will return false when conversation has no labels' do
|
||||
conversation.update_labels([])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when filter_operator is is_not_present' do
|
||||
before do
|
||||
rule.conditions = [
|
||||
{ 'values': [], 'attribute_key': 'labels', 'query_operator': nil, 'filter_operator': 'is_not_present' }
|
||||
]
|
||||
rule.save
|
||||
end
|
||||
|
||||
it 'will return false when conversation has any labels' do
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(false)
|
||||
end
|
||||
|
||||
it 'will return true when conversation has no labels' do
|
||||
conversation.update_labels([])
|
||||
expect(described_class.new(rule, conversation, { changed_attributes: {} }).perform).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe BaseTokenService do
|
||||
let(:payload) { { user_id: 1, exp: 5.minutes.from_now.to_i } }
|
||||
let(:token_service) { described_class.new(payload: payload) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with the provided payload' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'encodes the payload correctly' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#decode_token' do
|
||||
let(:token) { token_service.generate_token }
|
||||
let(:decoder_service) { described_class.new(token: token) }
|
||||
|
||||
it 'decodes a valid JWT token' do
|
||||
decoded = decoder_service.decode_token
|
||||
expect(decoded[:user_id]).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty hash for invalid token' do
|
||||
invalid_service = described_class.new(token: 'invalid_token')
|
||||
expect(invalid_service.decode_token).to eq({})
|
||||
end
|
||||
|
||||
it 'returns empty hash for expired token' do
|
||||
expired_payload = { user_id: 1, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
expired_service = described_class.new(token: expired_token)
|
||||
expect(expired_service.decode_token).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkActionService do
|
||||
subject(:service) { described_class.new(account: account, user: user, params: params) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when delete action is requested via action_name' do
|
||||
let(:params) { { ids: [1, 2], action_name: 'delete' } }
|
||||
|
||||
it 'delegates to the bulk delete service' do
|
||||
bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
|
||||
|
||||
expect(Contacts::BulkDeleteService).to receive(:new)
|
||||
.with(account: account, contact_ids: [1, 2])
|
||||
.and_return(bulk_delete_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when labels are provided' do
|
||||
let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
|
||||
|
||||
it 'delegates to the bulk assign labels service with permitted params' do
|
||||
bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
|
||||
|
||||
expect(Contacts::BulkAssignLabelsService).to receive(:new)
|
||||
.with(account: account, contact_ids: [10, 20], labels: %w[vip support])
|
||||
.and_return(bulk_assign_service)
|
||||
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,48 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkAssignLabelsService do
|
||||
subject(:service) do
|
||||
described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id, contact_two.id, other_contact.id],
|
||||
labels: labels
|
||||
)
|
||||
end
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let!(:other_contact) { create(:contact) }
|
||||
let(:labels) { %w[vip support] }
|
||||
|
||||
it 'assigns labels to the contacts that belong to the account' do
|
||||
service.perform
|
||||
|
||||
expect(contact_one.reload.label_list).to include(*labels)
|
||||
expect(contact_two.reload.label_list).to include(*labels)
|
||||
end
|
||||
|
||||
it 'does not assign labels to contacts outside the account' do
|
||||
service.perform
|
||||
|
||||
expect(other_contact.reload.label_list).to be_empty
|
||||
end
|
||||
|
||||
it 'returns ids of contacts that were updated' do
|
||||
result = service.perform
|
||||
|
||||
expect(result[:success]).to be(true)
|
||||
expect(result[:updated_contact_ids]).to contain_exactly(contact_one.id, contact_two.id)
|
||||
end
|
||||
|
||||
it 'returns success with no updates when labels are blank' do
|
||||
result = described_class.new(
|
||||
account: account,
|
||||
contact_ids: [contact_one.id],
|
||||
labels: []
|
||||
).perform
|
||||
|
||||
expect(result).to eq(success: true, updated_contact_ids: [])
|
||||
expect(contact_one.reload.label_list).to be_empty
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,24 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Contacts::BulkDeleteService do
|
||||
subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let!(:contact_one) { create(:contact, account: account) }
|
||||
let!(:contact_two) { create(:contact, account: account) }
|
||||
let(:contact_ids) { [contact_one.id, contact_two.id] }
|
||||
|
||||
describe '#perform' do
|
||||
it 'deletes the provided contacts' do
|
||||
expect { service.perform }
|
||||
.to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
|
||||
.and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
|
||||
end
|
||||
|
||||
it 'returns when no contact ids are provided' do
|
||||
empty_service = described_class.new(account: account, contact_ids: [])
|
||||
|
||||
expect { empty_service.perform }.not_to change(Contact, :count)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -7,9 +7,25 @@ describe Contacts::FilterService do
|
||||
let!(:first_user) { create(:user, account: account) }
|
||||
let!(:second_user) { create(:user, account: account) }
|
||||
let!(:inbox) { create(:inbox, account: account, enable_auto_assignment: false) }
|
||||
let!(:en_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'uk' }) }
|
||||
let!(:el_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'gr' }) }
|
||||
let!(:cs_contact) { create(:contact, account: account, additional_attributes: { 'country_code': 'cz' }) }
|
||||
let!(:en_contact) do
|
||||
create(:contact,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'uk' })
|
||||
end
|
||||
let!(:el_contact) do
|
||||
create(:contact,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'gr' })
|
||||
end
|
||||
let!(:cs_contact) do
|
||||
create(:contact,
|
||||
:with_phone_number,
|
||||
account: account,
|
||||
email: Faker::Internet.unique.email,
|
||||
additional_attributes: { 'country_code': 'cz' })
|
||||
end
|
||||
|
||||
before do
|
||||
create(:inbox_member, user: first_user, inbox: inbox)
|
||||
@@ -65,9 +81,50 @@ describe Contacts::FilterService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - phone' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [cs_contact.phone_number],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(cs_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - phone (without +)' do
|
||||
it 'filter contacts by name' do
|
||||
params[:payload] = [
|
||||
{
|
||||
attribute_key: 'phone_number',
|
||||
filter_operator: 'equal_to',
|
||||
values: [cs_contact.phone_number[1..]],
|
||||
query_operator: nil
|
||||
}.with_indifferent_access
|
||||
]
|
||||
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
expect(result[:count]).to be 1
|
||||
expect(result[:contacts].length).to be 1
|
||||
expect(result[:contacts].first.name).to eq(cs_contact.name)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with standard attributes - blocked' do
|
||||
it 'filter contacts by blocked' do
|
||||
blocked_contact = create(:contact, account: account, blocked: true)
|
||||
blocked_contact = create(
|
||||
:contact,
|
||||
account: account,
|
||||
blocked: true,
|
||||
email: Faker::Internet.unique.email
|
||||
)
|
||||
params = { payload: [{ attribute_key: 'blocked', filter_operator: 'equal_to', values: ['true'],
|
||||
query_operator: nil }.with_indifferent_access] }
|
||||
result = filter_service.new(account, first_user, params).perform
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Email::SendOnEmailService do
|
||||
let(:account) { create(:account) }
|
||||
let(:email_channel) { create(:channel_email, account: account) }
|
||||
let(:inbox) { create(:inbox, account: account, channel: email_channel) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
|
||||
describe '#perform' do
|
||||
let(:mailer_context) { instance_double(ConversationReplyMailer) }
|
||||
let(:delivery) { instance_double(ActionMailer::MessageDelivery) }
|
||||
let(:email_message) { instance_double(Mail::Message) }
|
||||
|
||||
before do
|
||||
allow(ConversationReplyMailer).to receive(:with).with(account: message.account).and_return(mailer_context)
|
||||
end
|
||||
|
||||
context 'when message is email notifiable' do
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
|
||||
allow(delivery).to receive(:deliver_now).and_return(email_message)
|
||||
allow(email_message).to receive(:message_id).and_return(
|
||||
"conversation/#{conversation.uuid}/messages/" \
|
||||
"#{message.id}@#{conversation.account.domain}"
|
||||
)
|
||||
end
|
||||
|
||||
it 'sends email via ConversationReplyMailer' do
|
||||
service.perform
|
||||
|
||||
expect(ConversationReplyMailer).to have_received(:with).with(account: message.account)
|
||||
expect(mailer_context).to have_received(:email_reply).with(message)
|
||||
expect(delivery).to have_received(:deliver_now)
|
||||
end
|
||||
|
||||
it 'updates message source id on success' do
|
||||
service.perform
|
||||
|
||||
expect(message.reload.source_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is not email notifiable' do
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
|
||||
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply)
|
||||
end
|
||||
|
||||
it 'does not send email' do
|
||||
service.perform
|
||||
|
||||
expect(ConversationReplyMailer).not_to have_received(:with)
|
||||
expect(mailer_context).not_to have_received(:email_reply)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when an error occurs' do
|
||||
let(:error_message) { 'SMTP connection failed' }
|
||||
let(:error) { StandardError.new(error_message) }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
let(:status_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
|
||||
before do
|
||||
allow(mailer_context).to receive(:email_reply).with(message).and_return(delivery)
|
||||
allow(delivery).to receive(:deliver_now).and_raise(error)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
it 'captures the exception' do
|
||||
expect(ChatwootExceptionTracker).to receive(:new).with(error, account: message.account)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'updates message status to failed' do
|
||||
service.perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.reload.external_error).to eq(error_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -35,6 +35,62 @@ describe Line::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:follow_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:multi_user_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f1',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'id': '3257081',
|
||||
'type': 'text',
|
||||
'text': 'Hello, world 1'
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f2',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af49806292'
|
||||
},
|
||||
'message': {
|
||||
'id': '3257082',
|
||||
'type': 'text',
|
||||
'text': 'Hello, world 2'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:image_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
@@ -105,6 +161,40 @@ describe Line::IncomingMessageService do
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:file_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
'events': [
|
||||
{
|
||||
'replyToken': '0f3779fba3b349968c5d07db31eab56f',
|
||||
'type': 'message',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
},
|
||||
'message': {
|
||||
'type': 'file',
|
||||
'id': '354718',
|
||||
'fileName': 'contacts.csv',
|
||||
'fileSize': 2978
|
||||
}
|
||||
},
|
||||
{
|
||||
'replyToken': '8cf9239d56244f4197887e939187e19e',
|
||||
'type': 'follow',
|
||||
'mode': 'active',
|
||||
'timestamp': 1_462_629_479_859,
|
||||
'source': {
|
||||
'type': 'user',
|
||||
'userId': 'U4af4980629'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.with_indifferent_access
|
||||
end
|
||||
|
||||
let(:sticker_params) do
|
||||
{
|
||||
'destination': '2342234234',
|
||||
@@ -141,8 +231,8 @@ describe Line::IncomingMessageService do
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
context 'when valid text message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
context 'when non-text message params' do
|
||||
it 'does not create conversations, messages and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
@@ -154,12 +244,56 @@ describe Line::IncomingMessageService do
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: follow_params).perform
|
||||
expect(line_channel.inbox.conversations.size).to eq(0)
|
||||
expect(Contact.all.size).to eq(0)
|
||||
expect(line_channel.inbox.messages.size).to eq(0)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid text message params' do
|
||||
let(:line_bot) { double }
|
||||
let(:line_user_profile) { double }
|
||||
|
||||
before do
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).with('U4af4980629').and_return(line_user_profile)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
described_class.new(inbox: line_channel.inbox, params: params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('Hello, world')
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts for multi user' do
|
||||
line_user_profile2 = double
|
||||
allow(line_bot).to receive(:get_profile).with('U4af49806292').and_return(line_user_profile2)
|
||||
allow(line_user_profile2).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test 2',
|
||||
'userId': 'U4af49806292',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: multi_user_params).perform
|
||||
expect(line_channel.inbox.conversations.size).to eq(2)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(Contact.all.last.name).to eq('LINE Test 2')
|
||||
expect(Contact.all.last.additional_attributes['social_line_user_id']).to eq('U4af49806292')
|
||||
expect(line_channel.inbox.messages.first.content).to eq('Hello, world 1')
|
||||
expect(line_channel.inbox.messages.last.content).to eq('Hello, world 2')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid sticker message params' do
|
||||
@@ -241,5 +375,35 @@ describe Line::IncomingMessageService do
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('media-354718.mp4')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when valid file message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
line_bot = double
|
||||
line_user_profile = double
|
||||
allow(Line::Bot::Client).to receive(:new).and_return(line_bot)
|
||||
allow(line_bot).to receive(:get_profile).and_return(line_user_profile)
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/contacts.csv'), 'text/csv')
|
||||
allow(line_bot).to receive(:get_message_content).and_return(
|
||||
OpenStruct.new({
|
||||
body: Base64.encode64(file.read),
|
||||
content_type: 'text/csv'
|
||||
})
|
||||
)
|
||||
allow(line_user_profile).to receive(:body).and_return(
|
||||
{
|
||||
'displayName': 'LINE Test',
|
||||
'userId': 'U4af4980629',
|
||||
'pictureUrl': 'https://test.com'
|
||||
}.to_json
|
||||
)
|
||||
described_class.new(inbox: line_channel.inbox, params: file_params).perform
|
||||
expect(line_channel.inbox.conversations).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('LINE Test')
|
||||
expect(Contact.all.first.additional_attributes['social_line_user_id']).to eq('U4af4980629')
|
||||
expect(line_channel.inbox.messages.first.content).to be_nil
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file_type).to eq('file')
|
||||
expect(line_channel.inbox.messages.first.attachments.first.file.blob.filename.to_s).to eq('contacts.csv')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
content: 'Hello, I need help'
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
:bot_message,
|
||||
conversation: conversation,
|
||||
message_type: 'outgoing',
|
||||
content: 'Thanks for reaching out, an agent will reach out to you soon'
|
||||
)
|
||||
|
||||
create(
|
||||
:message,
|
||||
conversation: conversation,
|
||||
@@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
||||
"Channel: #{conversation.inbox.channel.name}",
|
||||
'Message History:',
|
||||
'User: Hello, I need help',
|
||||
'Support agent: How can I assist you today?',
|
||||
'Bot: Thanks for reaching out, an agent will reach out to you soon',
|
||||
'Support Agent: How can I assist you today?',
|
||||
''
|
||||
].join("\n")
|
||||
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Messages::SendEmailNotificationService do
|
||||
let(:account) { create(:account) }
|
||||
let(:conversation) { create(:conversation, account: account) }
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'outgoing') }
|
||||
let(:service) { described_class.new(message: message) }
|
||||
|
||||
describe '#perform' do
|
||||
context 'when email notification should be sent' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
allow(Redis::Alfred).to receive(:set).and_return(true)
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
end
|
||||
|
||||
it 'enqueues ConversationReplyEmailJob' do
|
||||
expect { service.perform }.to have_enqueued_job(ConversationReplyEmailJob).with(conversation.id, message.id).on_queue('mailers')
|
||||
end
|
||||
|
||||
it 'atomically sets redis key to prevent duplicate emails' do
|
||||
expected_key = format(Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id)
|
||||
|
||||
service.perform
|
||||
|
||||
expect(Redis::Alfred).to have_received(:set).with(expected_key, message.id, nx: true, ex: 1.hour.to_i)
|
||||
end
|
||||
|
||||
context 'when redis key already exists' do
|
||||
before do
|
||||
allow(Redis::Alfred).to receive(:set).and_return(false)
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
|
||||
it 'attempts atomic set once' do
|
||||
service.perform
|
||||
|
||||
expect(Redis::Alfred).to have_received(:set).once
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when handling concurrent requests' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'prevents duplicate jobs under race conditions' do
|
||||
# Create 5 threads that simultaneously try to enqueue workers for the same conversation
|
||||
threads = Array.new(5) do
|
||||
Thread.new do
|
||||
msg = create(:message, conversation: conversation, message_type: 'outgoing')
|
||||
described_class.new(message: msg).perform
|
||||
end
|
||||
end
|
||||
|
||||
threads.each(&:join)
|
||||
|
||||
# Only ONE job should be scheduled despite 5 concurrent attempts
|
||||
jobs_for_conversation = ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
|
||||
job[:job] == ConversationReplyEmailJob && job[:args].first == conversation.id
|
||||
end
|
||||
expect(jobs_for_conversation.size).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when email notification should not be sent' do
|
||||
before do
|
||||
ActiveJob::Base.queue_adapter = :test
|
||||
end
|
||||
|
||||
context 'when message is not email notifiable' do
|
||||
let(:message) { create(:message, conversation: conversation, message_type: 'incoming') }
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when contact has no email' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: nil)
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel does not support email notifications' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_sms, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'does not enqueue job' do
|
||||
expect { service.perform }.not_to have_enqueued_job(ConversationReplyEmailJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#should_send_email_notification?' do
|
||||
context 'with WebWidget channel' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: true)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'returns true when continuity_via_email is enabled' do
|
||||
expect(service.send(:should_send_email_notification?)).to be true
|
||||
end
|
||||
|
||||
context 'when continuity_via_email is disabled' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_widget, account: account, continuity_via_email: false)) }
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with API channel' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_api, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(true)
|
||||
end
|
||||
|
||||
it 'returns true when email_continuity_on_api_channel feature is enabled' do
|
||||
expect(service.send(:should_send_email_notification?)).to be true
|
||||
end
|
||||
|
||||
context 'when email_continuity_on_api_channel feature is disabled' do
|
||||
before do
|
||||
allow(account).to receive(:feature_enabled?).and_return(false)
|
||||
allow(account).to receive(:feature_enabled?).with('email_continuity_on_api_channel').and_return(false)
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with other channels' do
|
||||
let(:inbox) { create(:inbox, account: account, channel: create(:channel_email, account: account)) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox) }
|
||||
|
||||
before do
|
||||
conversation.contact.update!(email: 'test@example.com')
|
||||
end
|
||||
|
||||
it 'returns false' do
|
||||
expect(service.send(:should_send_email_notification?)).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,106 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::AuthenticationService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
user.enable_two_factor!
|
||||
user.update!(otp_required_for_login: true)
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
|
||||
describe '#authenticate' do
|
||||
context 'with OTP code' do
|
||||
context 'when OTP is valid' do
|
||||
it 'returns true' do
|
||||
valid_otp = user.current_otp
|
||||
service = described_class.new(user: user, otp_code: valid_otp)
|
||||
expect(service.authenticate).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: '000000')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when OTP is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, otp_code: nil)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with backup code' do
|
||||
let(:backup_codes) { user.generate_backup_codes! }
|
||||
|
||||
context 'when backup code is valid' do
|
||||
it 'returns true and invalidates the code' do
|
||||
valid_code = backup_codes.first
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
|
||||
# Code should be invalidated after use
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code is invalid' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user, backup_code: 'invalid')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when backup code has already been used' do
|
||||
it 'returns false' do
|
||||
valid_code = backup_codes.first
|
||||
# Use the code once
|
||||
service = described_class.new(user: user, backup_code: valid_code)
|
||||
service.authenticate
|
||||
|
||||
# Try to use it again
|
||||
service2 = described_class.new(user: user.reload, backup_code: valid_code)
|
||||
expect(service2.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with neither OTP nor backup code' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: user)
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user is nil' do
|
||||
it 'returns false' do
|
||||
service = described_class.new(user: nil, otp_code: '123456')
|
||||
expect(service.authenticate).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
context 'when both OTP and backup code are provided' do
|
||||
it 'uses OTP authentication first' do
|
||||
valid_otp = user.current_otp
|
||||
backup_codes = user.generate_backup_codes!
|
||||
|
||||
service = described_class.new(
|
||||
user: user,
|
||||
otp_code: valid_otp,
|
||||
backup_code: backup_codes.first
|
||||
)
|
||||
|
||||
expect(service.authenticate).to be_truthy
|
||||
# Backup code should not be consumed
|
||||
user.reload
|
||||
expect(user.otp_backup_codes).not_to include('XXXXXXXX')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,72 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Mfa::TokenService do
|
||||
before do
|
||||
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
|
||||
end
|
||||
|
||||
let(:user) { create(:user) }
|
||||
let(:token_service) { described_class.new(user: user) }
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with user_id' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'includes user_id in the payload' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['user_id']).to eq(user.id)
|
||||
end
|
||||
|
||||
it 'sets expiration to 5 minutes from now' do
|
||||
allow(Time).to receive(:now).and_return(Time.zone.parse('2024-01-01 12:00:00'))
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expected_exp = Time.zone.parse('2024-01-01 12:05:00').to_i
|
||||
expect(decoded['exp']).to eq(expected_exp)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#verify_token' do
|
||||
let(:valid_token) { token_service.generate_token }
|
||||
|
||||
context 'with valid token' do
|
||||
it 'returns the user' do
|
||||
verifier = described_class.new(token: valid_token)
|
||||
verified_user = verifier.verify_token
|
||||
expect(verified_user).to eq(user)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid token' do
|
||||
it 'returns nil for malformed token' do
|
||||
verifier = described_class.new(token: 'invalid_token')
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for expired token' do
|
||||
expired_payload = { user_id: user.id, exp: 1.minute.ago.to_i }
|
||||
expired_token = JWT.encode(expired_payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: expired_token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
|
||||
it 'returns nil for non-existent user' do
|
||||
payload = { user_id: 999_999, exp: 5.minutes.from_now.to_i }
|
||||
token = JWT.encode(payload, Rails.application.secret_key_base, 'HS256')
|
||||
verifier = described_class.new(token: token)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context 'with blank token' do
|
||||
it 'returns nil' do
|
||||
verifier = described_class.new(token: nil)
|
||||
expect(verifier.verify_token).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -411,4 +411,75 @@ describe Telegram::IncomingMessageService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is enabled' do
|
||||
before do
|
||||
# ensure message_params exists in this context and has from.id
|
||||
message_params[:from] ||= {}
|
||||
message_params[:from][:id] ||= 23
|
||||
end
|
||||
|
||||
it 'reopens last conversation if last conversation is resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: true)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
resolved_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(resolved_conversation.reload.messages.last.content).to eq('test')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when lock to single conversation is disabled' do
|
||||
before do
|
||||
# ensure message_params exists in this context and has from.id
|
||||
message_params[:from] ||= {}
|
||||
message_params[:from][:id] ||= 23
|
||||
end
|
||||
|
||||
it 'creates new conversation if last conversation is resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: false)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
_resolved_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :resolved)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(2)
|
||||
expect(telegram_channel.inbox.conversations.last.messages.first.content).to eq('test')
|
||||
expect(telegram_channel.inbox.conversations.last.status).to eq('open')
|
||||
end
|
||||
|
||||
it 'appends to last conversation if last conversation is not resolved' do
|
||||
telegram_channel.inbox.update!(lock_to_single_conversation: false)
|
||||
contact_inbox = ContactInbox.find_or_create_by(inbox: telegram_channel.inbox, source_id: message_params[:from][:id]) do |ci|
|
||||
ci.contact = create(:contact)
|
||||
end
|
||||
open_conversation = create(:conversation, inbox: telegram_channel.inbox, contact_inbox: contact_inbox, status: :open)
|
||||
|
||||
params = {
|
||||
'update_id' => 2_342_342_343_242,
|
||||
'message' => { 'text' => 'test' }.merge(message_params)
|
||||
}.with_indifferent_access
|
||||
|
||||
described_class.new(inbox: telegram_channel.inbox, params: params).perform
|
||||
|
||||
expect(telegram_channel.inbox.conversations.count).to eq(1)
|
||||
expect(open_conversation.reload.messages.last.content).to eq('test')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -402,6 +402,230 @@ describe Twilio::IncomingMessageService do
|
||||
existing_contact.reload
|
||||
expect(existing_contact.name).to eq('Alice Johnson')
|
||||
end
|
||||
|
||||
describe 'When the incoming number is a Brazilian number in new format with 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('João Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists' do
|
||||
# Create existing contact with same format
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5541988887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Another message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Another message from Brazil')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is a Brazilian number in old format without the 9 included' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in old format' do
|
||||
# Create existing contact with old format (12 digits)
|
||||
old_contact = create(:contact, account: account, phone_number: '+554188887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+554188887777', contact: old_contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil old format',
|
||||
ProfileName: 'Maria Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil old format')
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists in new format' do
|
||||
# Create existing contact with new format (13 digits)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+5541988887777')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+5541988887777', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with old format (12 digits)
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'João Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Brazil')
|
||||
# Should use the existing contact's source_id (normalized format)
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5541988887777')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+554188887777',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Brazil',
|
||||
ProfileName: 'Carlos Silva'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Silva')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Brazil')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+554188887777')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming number is an Argentine number with 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Carlos Mendoza')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+5491123456789')
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||
# Create existing contact with normalized format (without 9 after country code)
|
||||
normalized_contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: normalized_contact,
|
||||
inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
# Incoming message with 9 after country code
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+5491123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Carlos Mendoza'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# Should find and use existing contact, not create duplicate
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the existing conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
# Should use the normalized source_id from existing contact
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming number is an Argentine number without 9 after country code' do
|
||||
let!(:whatsapp_twilio_channel) do
|
||||
create(:channel_twilio_sms, :whatsapp, account: account, account_sid: 'ACxxx',
|
||||
inbox: create(:inbox, account: account, greeting_enabled: false))
|
||||
end
|
||||
|
||||
it 'appends to existing contact when contact inbox exists with same format' do
|
||||
# Create existing contact with same format (without 9)
|
||||
contact = create(:contact, account: account, phone_number: '+541123456789')
|
||||
contact_inbox = create(:contact_inbox, source_id: 'whatsapp:+541123456789', contact: contact, inbox: whatsapp_twilio_channel.inbox)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_twilio_channel.inbox, contact_inbox: contact_inbox)
|
||||
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Ana García'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
# No new conversation should be created
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).to eq(1)
|
||||
# Message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq('Test message from Argentina')
|
||||
end
|
||||
|
||||
it 'creates contact inbox with incoming number when no existing contact' do
|
||||
params = {
|
||||
SmsSid: 'SMxx',
|
||||
From: 'whatsapp:+541123456789',
|
||||
AccountSid: 'ACxxx',
|
||||
MessagingServiceSid: whatsapp_twilio_channel.messaging_service_sid,
|
||||
Body: 'Test message from Argentina',
|
||||
ProfileName: 'Diego López'
|
||||
}
|
||||
|
||||
described_class.new(params: params).perform
|
||||
|
||||
expect(whatsapp_twilio_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(whatsapp_twilio_channel.inbox.contacts.first.name).to eq('Diego López')
|
||||
expect(whatsapp_twilio_channel.inbox.messages.first.content).to eq('Test message from Argentina')
|
||||
expect(whatsapp_twilio_channel.inbox.contact_inboxes.first.source_id).to eq('whatsapp:+541123456789')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -48,6 +48,15 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
allow(channel_creation).to receive(:perform).and_return(channel)
|
||||
|
||||
allow(channel).to receive(:setup_webhooks)
|
||||
allow(channel).to receive(:phone_number).and_return('+1234567890')
|
||||
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
|
||||
it 'creates channel and sets up webhooks' do
|
||||
@@ -57,6 +66,49 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
it 'checks health status after channel creation' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
expect(health_service).to receive(:fetch_health_status)
|
||||
|
||||
service.perform
|
||||
end
|
||||
|
||||
context 'when channel is in pending state' do
|
||||
it 'prompts reauthorization for pending channel' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'NOT_APPLICABLE',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when throughput level is NOT_APPLICABLE' do
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'NOT_APPLICABLE' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
|
||||
expect(channel).to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when channel is healthy' do
|
||||
it 'does not prompt reauthorization for healthy channel' do
|
||||
expect(channel).not_to receive(:prompt_reauthorization!)
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
|
||||
context 'when parameters are invalid' do
|
||||
it 'raises ArgumentError for missing parameters' do
|
||||
invalid_service = described_class.new(account: account, params: { code: '', business_id: '', waba_id: '' })
|
||||
@@ -114,6 +166,16 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
allow(reauth_service).to receive(:perform).with(access_token, phone_info).and_return(channel)
|
||||
|
||||
allow(channel).to receive(:phone_number).and_return('+1234567890')
|
||||
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
|
||||
it 'uses ReauthorizationService and sets up webhooks' do
|
||||
@@ -124,36 +186,57 @@ describe Whatsapp::EmbeddedSignupService do
|
||||
expect(result).to eq(channel)
|
||||
end
|
||||
|
||||
it 'clears reauthorization flag' do
|
||||
inbox = create(:inbox, account: account)
|
||||
whatsapp_channel = create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
inbox.update!(channel: whatsapp_channel)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
context 'with real channel requiring reauthorization' do
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:whatsapp_channel) do
|
||||
create(:channel_whatsapp, account: account, phone_number: '+1234567890',
|
||||
validate_provider_config: false, sync_templates: false)
|
||||
end
|
||||
let(:service_with_real_inbox) { described_class.new(account: account, params: params, inbox_id: inbox.id) }
|
||||
|
||||
service_with_real_inbox = described_class.new(account: account, params: params, inbox_id: inbox.id)
|
||||
before do
|
||||
inbox.update!(channel: whatsapp_channel)
|
||||
whatsapp_channel.prompt_reauthorization!
|
||||
|
||||
# Mock the ReauthorizationService to return our test channel
|
||||
reauth_service = instance_double(Whatsapp::ReauthorizationService)
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox.id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
|
||||
# Perform the reauthorization and clear the flag
|
||||
allow(reauth_service).to receive(:perform) do
|
||||
whatsapp_channel.reauthorized!
|
||||
whatsapp_channel
|
||||
setup_reauthorization_mocks
|
||||
setup_health_service_mock
|
||||
end
|
||||
|
||||
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
|
||||
it 'clears reauthorization flag when reauthorization completes' do
|
||||
expect(whatsapp_channel.reauthorization_required?).to be true
|
||||
result = service_with_real_inbox.perform
|
||||
expect(result).to eq(whatsapp_channel)
|
||||
expect(whatsapp_channel.reauthorization_required?).to be false
|
||||
end
|
||||
|
||||
expect(whatsapp_channel.reauthorization_required?).to be true
|
||||
result = service_with_real_inbox.perform
|
||||
expect(result).to eq(whatsapp_channel)
|
||||
expect(whatsapp_channel.reauthorization_required?).to be false
|
||||
private
|
||||
|
||||
def setup_reauthorization_mocks
|
||||
reauth_service = instance_double(Whatsapp::ReauthorizationService)
|
||||
allow(Whatsapp::ReauthorizationService).to receive(:new).with(
|
||||
account: account,
|
||||
inbox_id: inbox.id,
|
||||
phone_number_id: params[:phone_number_id],
|
||||
business_id: params[:business_id]
|
||||
).and_return(reauth_service)
|
||||
|
||||
allow(reauth_service).to receive(:perform) do
|
||||
whatsapp_channel.reauthorized!
|
||||
whatsapp_channel
|
||||
end
|
||||
|
||||
allow(whatsapp_channel).to receive(:setup_webhooks).and_return(true)
|
||||
end
|
||||
|
||||
def setup_health_service_mock
|
||||
health_service = instance_double(Whatsapp::HealthService)
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'CLOUD_API',
|
||||
throughput: { 'level' => 'STANDARD' },
|
||||
messaging_limit_tier: 'TIER_1000'
|
||||
})
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -341,6 +341,58 @@ describe Whatsapp::IncomingMessageService do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When the incoming waid is an Argentine number with 9 after country code' do
|
||||
let(:wa_id) { '5491123456789' }
|
||||
|
||||
it 'creates appropriate conversations, message and contacts if contact does not exist' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||
end
|
||||
|
||||
it 'appends to existing contact if contact inbox exists with normalized format' do
|
||||
# Normalized format removes the 9 after country code
|
||||
normalized_wa_id = '541123456789'
|
||||
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: normalized_wa_id)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||
# message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||
# should use the normalized wa_id from existing contact
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(normalized_wa_id)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'When incoming waid is an Argentine number without 9 after country code' do
|
||||
let(:wa_id) { '541123456789' }
|
||||
|
||||
context 'when a contact inbox exists with the same format' do
|
||||
it 'appends to existing contact' do
|
||||
contact_inbox = create(:contact_inbox, inbox: whatsapp_channel.inbox, source_id: wa_id)
|
||||
last_conversation = create(:conversation, inbox: whatsapp_channel.inbox, contact_inbox: contact_inbox)
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
# no new conversation should be created
|
||||
expect(whatsapp_channel.inbox.conversations.count).to eq(1)
|
||||
# message appended to the last conversation
|
||||
expect(last_conversation.messages.last.content).to eq(params[:messages].first[:text][:body])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a contact inbox does not exist' do
|
||||
it 'creates contact inbox with the incoming waid' do
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Test')
|
||||
expect(whatsapp_channel.inbox.contact_inboxes.first.source_id).to eq(wa_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'when message processing is in progress' do
|
||||
it 'ignores the current message creation request' do
|
||||
params = { 'contacts' => [{ 'profile' => { 'name' => 'Kedar' }, 'wa_id' => '919746334593' }],
|
||||
|
||||
@@ -29,32 +29,23 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
|
||||
context 'when valid attachment message params' do
|
||||
it 'creates appropriate conversations, message and contacts' do
|
||||
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
url: 'https://chatwoot-assets.local/sample.png',
|
||||
mime_type: 'image/jpeg',
|
||||
sha256: 'sha256',
|
||||
file_size: 'SIZE',
|
||||
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683'
|
||||
}.to_json,
|
||||
headers: { 'content-type' => 'application/json' }
|
||||
)
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.png')
|
||||
)
|
||||
|
||||
stub_media_url_request
|
||||
stub_sample_png_request
|
||||
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
||||
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
|
||||
expect_conversation_created
|
||||
expect_contact_name
|
||||
expect_message_content
|
||||
expect_message_has_attachment
|
||||
end
|
||||
|
||||
it 'increments reauthorization count if fetching attachment fails' do
|
||||
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
|
||||
stub_request(
|
||||
:get,
|
||||
whatsapp_channel.media_url(
|
||||
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
whatsapp_channel.provider_config['phone_number_id']
|
||||
)
|
||||
).to_return(
|
||||
status: 401
|
||||
)
|
||||
|
||||
@@ -115,4 +106,50 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Métodos auxiliares para reduzir o tamanho do exemplo
|
||||
|
||||
def stub_media_url_request
|
||||
stub_request(
|
||||
:get,
|
||||
whatsapp_channel.media_url(
|
||||
'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
|
||||
whatsapp_channel.provider_config['phone_number_id']
|
||||
)
|
||||
).to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
messaging_product: 'whatsapp',
|
||||
url: 'https://chatwoot-assets.local/sample.png',
|
||||
mime_type: 'image/jpeg',
|
||||
sha256: 'sha256',
|
||||
file_size: 'SIZE',
|
||||
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683'
|
||||
}.to_json,
|
||||
headers: { 'content-type' => 'application/json' }
|
||||
)
|
||||
end
|
||||
|
||||
def stub_sample_png_request
|
||||
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
|
||||
status: 200,
|
||||
body: File.read('spec/assets/sample.png')
|
||||
)
|
||||
end
|
||||
|
||||
def expect_conversation_created
|
||||
expect(whatsapp_channel.inbox.conversations.count).not_to eq(0)
|
||||
end
|
||||
|
||||
def expect_contact_name
|
||||
expect(Contact.all.first.name).to eq('Sojan Jose')
|
||||
end
|
||||
|
||||
def expect_message_content
|
||||
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
|
||||
end
|
||||
|
||||
def expect_message_has_attachment
|
||||
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Whatsapp::PopulateTemplateParametersService do
|
||||
let(:service) { described_class.new }
|
||||
|
||||
describe '#normalize_url' do
|
||||
it 'normalizes URLs with spaces' do
|
||||
url_with_spaces = 'https://example.com/path with spaces'
|
||||
normalized = service.send(:normalize_url, url_with_spaces)
|
||||
|
||||
expect(normalized).to eq('https://example.com/path%20with%20spaces')
|
||||
end
|
||||
|
||||
it 'handles URLs with special characters' do
|
||||
url = 'https://example.com/path?query=test value'
|
||||
normalized = service.send(:normalize_url, url)
|
||||
|
||||
expect(normalized).to include('https://example.com/path')
|
||||
expect(normalized).not_to include(' ')
|
||||
end
|
||||
|
||||
it 'returns valid URLs unchanged' do
|
||||
url = 'https://example.com/valid-path'
|
||||
normalized = service.send(:normalize_url, url)
|
||||
|
||||
expect(normalized).to eq(url)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#build_media_parameter' do
|
||||
context 'when URL contains spaces' do
|
||||
it 'normalizes the URL before building media parameter' do
|
||||
url_with_spaces = 'https://example.com/image with spaces.jpg'
|
||||
result = service.build_media_parameter(url_with_spaces, 'IMAGE')
|
||||
|
||||
expect(result[:type]).to eq('image')
|
||||
expect(result[:image][:link]).to eq('https://example.com/image%20with%20spaces.jpg')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL contains special characters in query string' do
|
||||
it 'normalizes the URL correctly' do
|
||||
url = 'https://example.com/video.mp4?title=My Video'
|
||||
result = service.build_media_parameter(url, 'VIDEO', 'test_video')
|
||||
|
||||
expect(result[:type]).to eq('video')
|
||||
expect(result[:video][:link]).not_to include(' ')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL is already valid' do
|
||||
it 'builds media parameter without changing URL' do
|
||||
url = 'https://example.com/document.pdf'
|
||||
result = service.build_media_parameter(url, 'DOCUMENT', 'test.pdf')
|
||||
|
||||
expect(result[:type]).to eq('document')
|
||||
expect(result[:document][:link]).to eq(url)
|
||||
expect(result[:document][:filename]).to eq('test.pdf')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when URL is blank' do
|
||||
it 'returns nil' do
|
||||
result = service.build_media_parameter('', 'IMAGE')
|
||||
|
||||
expect(result).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -16,6 +16,7 @@ describe Whatsapp::WebhookSetupService do
|
||||
let(:access_token) { 'test_access_token' }
|
||||
let(:service) { described_class.new(channel, waba_id, access_token) }
|
||||
let(:api_client) { instance_double(Whatsapp::FacebookApiClient) }
|
||||
let(:health_service) { instance_double(Whatsapp::HealthService) }
|
||||
|
||||
before do
|
||||
# Stub webhook teardown to prevent HTTP calls during cleanup
|
||||
@@ -24,8 +25,14 @@ describe Whatsapp::WebhookSetupService do
|
||||
# Clean up any existing channels to avoid phone number conflicts
|
||||
Channel::Whatsapp.destroy_all
|
||||
allow(Whatsapp::FacebookApiClient).to receive(:new).and_return(api_client)
|
||||
# Default stub for phone_number_verified? with any argument
|
||||
allow(Whatsapp::HealthService).to receive(:new).and_return(health_service)
|
||||
|
||||
# Default stubs for phone_number_verified? and health service
|
||||
allow(api_client).to receive(:phone_number_verified?).and_return(false)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
@@ -49,9 +56,13 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone number IS verified (should NOT register)' do
|
||||
context 'when phone number IS verified AND fully provisioned (should NOT register)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
@@ -66,16 +77,68 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone number IS verified BUT needs registration (pending provisioning)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'NOT_APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'registers the phone number due to pending provisioning state' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone number needs registration due to throughput level' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'NOT_APPLICABLE' }
|
||||
})
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'registers the phone number due to throughput not applicable' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number).with('123456789', 223_456)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, 'https://app.chatwoot.com/webhooks/whatsapp/+1234567890', 'test_verify_token')
|
||||
service.perform
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone_number_verified? raises error' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_raise('API down')
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(SecureRandom).to receive(:random_number).with(900_000).and_return(123_456)
|
||||
allow(api_client).to receive(:register_phone_number)
|
||||
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
|
||||
allow(channel).to receive(:save!)
|
||||
end
|
||||
|
||||
it 'tries to register phone and proceeds with webhook setup' do
|
||||
it 'tries to register phone (due to verification error) and proceeds with webhook setup' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
@@ -84,6 +147,22 @@ describe Whatsapp::WebhookSetupService do
|
||||
end
|
||||
end
|
||||
|
||||
context 'when health service raises error' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_raise('Health API down')
|
||||
allow(api_client).to receive(:subscribe_waba_webhook).and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
it 'does not register phone (conservative approach) and proceeds with webhook setup' do
|
||||
with_modified_env FRONTEND_URL: 'https://app.chatwoot.com' do
|
||||
expect(api_client).not_to receive(:register_phone_number)
|
||||
expect(api_client).to receive(:subscribe_waba_webhook)
|
||||
expect { service.perform }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when phone registration fails (not blocking)' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(false)
|
||||
@@ -193,6 +272,10 @@ describe Whatsapp::WebhookSetupService do
|
||||
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'existing_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
@@ -218,6 +301,10 @@ describe Whatsapp::WebhookSetupService do
|
||||
context 'when webhook setup is successful in creation flow' do
|
||||
before do
|
||||
allow(api_client).to receive(:phone_number_verified?).with('123456789').and_return(true)
|
||||
allow(health_service).to receive(:fetch_health_status).and_return({
|
||||
platform_type: 'APPLICABLE',
|
||||
throughput: { level: 'APPLICABLE' }
|
||||
})
|
||||
allow(api_client).to receive(:subscribe_waba_webhook)
|
||||
.with(waba_id, anything, 'test_verify_token').and_return({ 'success' => true })
|
||||
end
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Widget::TokenService, type: :service do
|
||||
describe 'token expiry configuration' do
|
||||
let(:service) { described_class.new(payload: {}) }
|
||||
|
||||
before do
|
||||
# Clear any existing configs to ensure test isolation
|
||||
InstallationConfig.where(name: 'WIDGET_TOKEN_EXPIRY').destroy_all
|
||||
end
|
||||
|
||||
context 'with valid configuration' do
|
||||
before do
|
||||
create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '30')
|
||||
end
|
||||
|
||||
it 'uses the configured value for token expiry' do
|
||||
travel_to '2025-01-01' do
|
||||
token = service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['iat']).to eq(Time.zone.now.to_i)
|
||||
expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'with empty configuration' do
|
||||
before do
|
||||
create(:installation_config, name: 'WIDGET_TOKEN_EXPIRY', value: '')
|
||||
end
|
||||
|
||||
it 'uses the default expiry' do
|
||||
travel_to '2025-01-01' do
|
||||
token = service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['iat']).to eq(Time.zone.now.to_i)
|
||||
expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,43 @@
|
||||
require 'rails_helper'
|
||||
|
||||
describe Widget::TokenService do
|
||||
let(:payload) { { source_id: 'contact_123', inbox_id: 1 } }
|
||||
let(:token_service) { described_class.new(payload: payload) }
|
||||
|
||||
describe 'inheritance' do
|
||||
it 'inherits from BaseTokenService' do
|
||||
expect(described_class.superclass).to eq(BaseTokenService)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_token' do
|
||||
it 'generates a JWT token with the provided payload' do
|
||||
token = token_service.generate_token
|
||||
expect(token).to be_present
|
||||
expect(token).to be_a(String)
|
||||
end
|
||||
|
||||
it 'encodes the payload correctly' do
|
||||
token = token_service.generate_token
|
||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||
expect(decoded['source_id']).to eq('contact_123')
|
||||
expect(decoded['inbox_id']).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#decode_token' do
|
||||
let(:token) { token_service.generate_token }
|
||||
let(:decoder_service) { described_class.new(token: token) }
|
||||
|
||||
it 'decodes a valid JWT token' do
|
||||
decoded = decoder_service.decode_token
|
||||
expect(decoded[:source_id]).to eq('contact_123')
|
||||
expect(decoded[:inbox_id]).to eq(1)
|
||||
end
|
||||
|
||||
it 'returns empty hash for invalid token' do
|
||||
invalid_service = described_class.new(token: 'invalid_token')
|
||||
expect(invalid_service.decode_token).to eq({})
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user