diff --git a/app/jobs/send_reply_job.rb b/app/jobs/send_reply_job.rb index b1cee960b..0aba9f7f1 100644 --- a/app/jobs/send_reply_job.rb +++ b/app/jobs/send_reply_job.rb @@ -1,27 +1,29 @@ class SendReplyJob < ApplicationJob queue_as :high + CHANNEL_SERVICES = { + 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService, + 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService, + 'Channel::Line' => ::Line::SendOnLineService, + 'Channel::Telegram' => ::Telegram::SendOnTelegramService, + 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService, + 'Channel::Sms' => ::Sms::SendOnSmsService, + 'Channel::Instagram' => ::Instagram::SendOnInstagramService, + 'Channel::Email' => ::Email::SendOnEmailService, + 'Channel::WebWidget' => ::Messages::SendEmailNotificationService, + 'Channel::Api' => ::Messages::SendEmailNotificationService + }.freeze + def perform(message_id) message = Message.find(message_id) - conversation = message.conversation - channel_name = conversation.inbox.channel.class.to_s + channel_name = message.conversation.inbox.channel.class.to_s - services = { - 'Channel::TwitterProfile' => ::Twitter::SendOnTwitterService, - 'Channel::TwilioSms' => ::Twilio::SendOnTwilioService, - 'Channel::Line' => ::Line::SendOnLineService, - 'Channel::Telegram' => ::Telegram::SendOnTelegramService, - 'Channel::Whatsapp' => ::Whatsapp::SendOnWhatsappService, - 'Channel::Sms' => ::Sms::SendOnSmsService, - 'Channel::Instagram' => ::Instagram::SendOnInstagramService - } + return send_on_facebook_page(message) if channel_name == 'Channel::FacebookPage' - case channel_name - when 'Channel::FacebookPage' - send_on_facebook_page(message) - else - services[channel_name].new(message: message).perform if services[channel_name].present? - end + service_class = CHANNEL_SERVICES[channel_name] + return unless service_class + + service_class.new(message: message).perform end private diff --git a/app/mailers/conversation_reply_mailer.rb b/app/mailers/conversation_reply_mailer.rb index a82c65440..8dbe67bf8 100644 --- a/app/mailers/conversation_reply_mailer.rb +++ b/app/mailers/conversation_reply_mailer.rb @@ -39,8 +39,7 @@ class ConversationReplyMailer < ApplicationMailer init_conversation_attributes(message.conversation) @message = message - reply_mail_object = prepare_mail(true) - message.update(source_id: reply_mail_object.message_id) + prepare_mail(true) end def conversation_transcript(conversation, to_email) diff --git a/app/models/message.rb b/app/models/message.rb index 2079e31a9..5f98493d0 100644 --- a/app/models/message.rb +++ b/app/models/message.rb @@ -300,7 +300,6 @@ class Message < ApplicationRecord def execute_after_create_commit_callbacks # rails issue with order of active record callbacks being executed https://github.com/rails/rails/issues/20911 reopen_conversation - notify_via_mail set_conversation_activity dispatch_create_events send_reply @@ -386,48 +385,6 @@ class Message < ApplicationRecord ::MessageTemplates::HookExecutionService.new(message: self).perform end - def email_notifiable_webwidget? - inbox.web_widget? && inbox.channel.continuity_via_email - end - - def email_notifiable_api_channel? - inbox.api? && inbox.account.feature_enabled?('email_continuity_on_api_channel') - end - - def email_notifiable_channel? - email_notifiable_webwidget? || %w[Email].include?(inbox.inbox_type) || email_notifiable_api_channel? - end - - def can_notify_via_mail? - return unless email_notifiable_message? - return unless email_notifiable_channel? - return if conversation.contact.email.blank? - - true - end - - def notify_via_mail - return unless can_notify_via_mail? - - trigger_notify_via_mail - end - - def trigger_notify_via_mail - return EmailReplyWorker.perform_in(1.second, id) if inbox.inbox_type == 'Email' - - # will set a redis key for the conversation so that we don't need to send email for every new message - # last few messages coupled together is sent every 2 minutes rather than one email for each message - # if redis key exists there is an unprocessed job that will take care of delivering the email - return if Redis::Alfred.get(conversation_mail_key).present? - - Redis::Alfred.setex(conversation_mail_key, id) - ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, id) - end - - def conversation_mail_key - format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id) - end - def validate_attachments_limit(_attachment) errors.add(:attachments, message: 'exceeded maximum allowed') if attachments.size >= NUMBER_OF_PERMITTED_ATTACHMENTS end diff --git a/app/services/email/send_on_email_service.rb b/app/services/email/send_on_email_service.rb new file mode 100644 index 000000000..b3a0060b1 --- /dev/null +++ b/app/services/email/send_on_email_service.rb @@ -0,0 +1,18 @@ +class Email::SendOnEmailService < Base::SendOnChannelService + private + + def channel_class + Channel::Email + end + + def perform_reply + return unless message.email_notifiable_message? + + reply_mail = ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now + Rails.logger.info("Email message #{message.id} sent with source_id: #{reply_mail.message_id}") + message.update(source_id: reply_mail.message_id) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: message.account).capture_exception + Messages::StatusUpdateService.new(message, 'failed', e.message).perform + end +end diff --git a/app/services/messages/send_email_notification_service.rb b/app/services/messages/send_email_notification_service.rb new file mode 100644 index 000000000..87ba1a797 --- /dev/null +++ b/app/services/messages/send_email_notification_service.rb @@ -0,0 +1,38 @@ +class Messages::SendEmailNotificationService + pattr_initialize [:message!] + + def perform + return unless should_send_email_notification? + + conversation = message.conversation + conversation_mail_key = format(::Redis::Alfred::CONVERSATION_MAILER_KEY, conversation_id: conversation.id) + + # Atomically set redis key to prevent duplicate email workers. Keep the key alive longer than + # the worker delay (1 hour) so slow queues don't enqueue duplicate jobs, but let it expire if + # the worker never manages to clean up. + return unless Redis::Alfred.set(conversation_mail_key, message.id, nx: true, ex: 1.hour.to_i) + + ConversationReplyEmailWorker.perform_in(2.minutes, conversation.id, message.id) + end + + private + + def should_send_email_notification? + return false unless message.email_notifiable_message? + return false if message.conversation.contact.email.blank? + + email_reply_enabled? + end + + def email_reply_enabled? + inbox = message.inbox + case inbox.channel.class.to_s + when 'Channel::WebWidget' + inbox.channel.continuity_via_email + when 'Channel::Api' + inbox.account.feature_enabled?('email_continuity_on_api_channel') + else + false + end + end +end diff --git a/app/workers/email_reply_worker.rb b/app/workers/email_reply_worker.rb deleted file mode 100644 index 14b668637..000000000 --- a/app/workers/email_reply_worker.rb +++ /dev/null @@ -1,16 +0,0 @@ -class EmailReplyWorker - include Sidekiq::Worker - sidekiq_options queue: :mailers, retry: 3 - - def perform(message_id) - message = Message.find(message_id) - - return unless message.email_notifiable_message? - - # send the email - ConversationReplyMailer.with(account: message.account).email_reply(message).deliver_now - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: message.account).capture_exception - Messages::StatusUpdateService.new(message, 'failed', e.message).perform - end -end diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 96561f2ad..1ada67cc1 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -6,9 +6,21 @@ Sidekiq.configure_client do |config| config.redis = Redis::Config.app end +# Logs whenever a job is pulled off Redis for execution. +class ChatwootDequeuedLogger + def call(_worker, job, queue) + Sidekiq.logger.info("Dequeued #{job['class']} #{job['jid']} from #{queue}") + yield + end +end + Sidekiq.configure_server do |config| config.redis = Redis::Config.app + config.server_middleware do |chain| + chain.add ChatwootDequeuedLogger + end + # skip the default start stop logging if Rails.env.production? config.logger.formatter = Sidekiq::Logger::Formatters::JSON.new diff --git a/spec/jobs/send_reply_job_spec.rb b/spec/jobs/send_reply_job_spec.rb index f3c19c2f9..11d3f6b04 100644 --- a/spec/jobs/send_reply_job_spec.rb +++ b/spec/jobs/send_reply_job_spec.rb @@ -108,5 +108,32 @@ RSpec.describe SendReplyJob do expect(process_service).to receive(:perform) described_class.perform_now(message.id) end + + it 'calls ::Email::SendOnEmailService when its email message' do + email_channel = create(:channel_email) + message = create(:message, conversation: create(:conversation, inbox: email_channel.inbox)) + allow(Email::SendOnEmailService).to receive(:new).with(message: message).and_return(process_service) + expect(Email::SendOnEmailService).to receive(:new).with(message: message) + expect(process_service).to receive(:perform) + described_class.perform_now(message.id) + end + + it 'calls ::Messages::SendEmailNotificationService when its webwidget message' do + webwidget_channel = create(:channel_widget) + message = create(:message, conversation: create(:conversation, inbox: webwidget_channel.inbox)) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + expect(process_service).to receive(:perform) + described_class.perform_now(message.id) + end + + it 'calls ::Messages::SendEmailNotificationService when its api channel message' do + api_channel = create(:channel_api) + message = create(:message, conversation: create(:conversation, inbox: api_channel.inbox)) + allow(Messages::SendEmailNotificationService).to receive(:new).with(message: message).and_return(process_service) + expect(Messages::SendEmailNotificationService).to receive(:new).with(message: message) + expect(process_service).to receive(:perform) + described_class.perform_now(message.id) + end end end diff --git a/spec/mailers/conversation_reply_mailer_spec.rb b/spec/mailers/conversation_reply_mailer_spec.rb index 3f6395566..2576361fa 100644 --- a/spec/mailers/conversation_reply_mailer_spec.rb +++ b/spec/mailers/conversation_reply_mailer_spec.rb @@ -243,8 +243,8 @@ RSpec.describe ConversationReplyMailer do expect(mail.decoded).to include message.content end - it 'updates the source_id' do - expect(mail.message_id).to eq message.source_id + it 'builds messageID properly' do + expect(mail.message_id).to eq("conversation/#{conversation.uuid}/messages/#{message.id}@#{conversation.account.domain}") end context 'when message is a CSAT survey' do diff --git a/spec/models/message_spec.rb b/spec/models/message_spec.rb index c5e080677..49a1d1e51 100644 --- a/spec/models/message_spec.rb +++ b/spec/models/message_spec.rb @@ -316,43 +316,69 @@ RSpec.describe Message do end context 'with conversation continuity' do - it 'calls notify email method on after save for outgoing messages in website channel' do - allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true) - message.message_type = 'outgoing' - message.save! - expect(ConversationReplyEmailWorker).to have_received(:perform_in) + let(:inbox_with_continuity) do + create(:inbox, account: message.account, + channel: build(:channel_widget, account: message.account, continuity_via_email: true)) end - it 'does not call notify email for website channel if continuity is disabled' do - message.inbox = create(:inbox, account: message.account, - channel: build(:channel_widget, account: message.account, continuity_via_email: false)) - allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true) + it 'schedules email notification for outgoing messages in website channel' do + message.inbox = inbox_with_continuity + message.conversation.update!(inbox: inbox_with_continuity) + message.conversation.contact.update!(email: 'test@example.com') message.message_type = 'outgoing' - message.save! - expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + + # Perform jobs inline to test full integration + perform_enqueued_jobs do + message.save! + end + + # Verify the email worker is eventually scheduled through the service + jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id } + expect(jobs_for_conversation_count).to eq(1) end - it 'wont call notify email method for private notes' do + it 'does not schedule email for website channel if continuity is disabled' do + inbox_without_continuity = create(:inbox, account: message.account, + channel: build(:channel_widget, account: message.account, continuity_via_email: false)) + message.inbox = inbox_without_continuity + message.conversation.update!(inbox: inbox_without_continuity) + message.conversation.contact.update!(email: 'test@example.com') + message.message_type = 'outgoing' + + initial_job_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id } + + perform_enqueued_jobs do + message.save! + end + + # No new jobs should be scheduled for this conversation + jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id } + expect(jobs_for_conversation_count).to eq(initial_job_count) + end + + it 'does not schedule email for private notes' do + message.inbox = inbox_with_continuity + message.conversation.update!(inbox: inbox_with_continuity) + message.conversation.contact.update!(email: 'test@example.com') message.private = true - allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true) - message.save! - expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) - end - - it 'calls EmailReply worker if the channel is email' do - message.inbox = create(:inbox, account: message.account, channel: build(:channel_email, account: message.account)) - allow(EmailReplyWorker).to receive(:perform_in).and_return(true) message.message_type = 'outgoing' - message.content_attributes = { email: { text_content: { quoted: 'quoted text' } } } - message.save! - expect(EmailReplyWorker).to have_received(:perform_in).with(1.second, message.id) + + initial_job_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id } + + perform_enqueued_jobs do + message.save! + end + + # No new jobs should be scheduled for this conversation + jobs_for_conversation_count = ConversationReplyEmailWorker.jobs.count { |job| job['args'].first == message.conversation.id } + expect(jobs_for_conversation_count).to eq(initial_job_count) end - it 'wont call notify email method unless its website or email channel' do - message.inbox = create(:inbox, account: message.account, channel: build(:channel_api, account: message.account)) - allow(ConversationReplyEmailWorker).to receive(:perform_in).and_return(true) + it 'calls SendReplyJob for all channels' do + allow(SendReplyJob).to receive(:perform_later).and_return(true) + message.message_type = 'outgoing' message.save! - expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + expect(SendReplyJob).to have_received(:perform_later).with(message.id) end end end diff --git a/spec/services/email/send_on_email_service_spec.rb b/spec/services/email/send_on_email_service_spec.rb new file mode 100644 index 000000000..5a4997eb0 --- /dev/null +++ b/spec/services/email/send_on_email_service_spec.rb @@ -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 diff --git a/spec/services/messages/send_email_notification_service_spec.rb b/spec/services/messages/send_email_notification_service_spec.rb new file mode 100644 index 000000000..cda728f21 --- /dev/null +++ b/spec/services/messages/send_email_notification_service_spec.rb @@ -0,0 +1,190 @@ +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) + allow(ConversationReplyEmailWorker).to receive(:perform_in) + end + + it 'schedules ConversationReplyEmailWorker' do + service.perform + + expect(ConversationReplyEmailWorker).to have_received(:perform_in).with( + 2.minutes, + conversation.id, + message.id + ) + 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 schedule worker' do + service.perform + + expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + 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 workers 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 worker should be scheduled despite 5 concurrent attempts + jobs_for_conversation = ConversationReplyEmailWorker.jobs.select { |job| job['args'].first == conversation.id } + expect(jobs_for_conversation.size).to eq(1) + end + end + + context 'when email notification should not be sent' do + before do + allow(ConversationReplyEmailWorker).to receive(:perform_in) + end + + context 'when message is not email notifiable' do + let(:message) { create(:message, conversation: conversation, message_type: 'incoming') } + + it 'does not schedule worker' do + service.perform + + expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + 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 schedule worker' do + service.perform + + expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + 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 schedule worker' do + service.perform + + expect(ConversationReplyEmailWorker).not_to have_received(:perform_in) + 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 diff --git a/spec/workers/email_reply_worker_spec.rb b/spec/workers/email_reply_worker_spec.rb deleted file mode 100644 index 579378918..000000000 --- a/spec/workers/email_reply_worker_spec.rb +++ /dev/null @@ -1,58 +0,0 @@ -require 'rails_helper' - -RSpec.describe EmailReplyWorker, type: :worker do - let(:account) { create(:account) } - let(:channel) { create(:channel_email, account: account) } - let(:message) { create(:message, message_type: :outgoing, inbox: channel.inbox, account: account) } - let(:private_message) { create(:message, private: true, message_type: :outgoing, inbox: channel.inbox, account: account) } - let(:incoming_message) { create(:message, message_type: :incoming, inbox: channel.inbox, account: account) } - let(:template_message) { create(:message, message_type: :template, content_type: :input_csat, inbox: channel.inbox, account: account) } - let(:mailer) { double } - let(:mailer_action) { double } - - describe '#perform' do - context 'when emails are successfully sent' do - before do - allow(ConversationReplyMailer).to receive(:with).and_return(mailer) - allow(mailer).to receive(:email_reply).and_return(mailer_action) - allow(mailer_action).to receive(:deliver_now).and_return(true) - end - - it 'calls mailer action with message' do - described_class.new.perform(message.id) - expect(mailer).to have_received(:email_reply).with(message) - expect(mailer_action).to have_received(:deliver_now) - end - - it 'does not call mailer action with a private message' do - described_class.new.perform(private_message.id) - expect(mailer).not_to have_received(:email_reply) - expect(mailer_action).not_to have_received(:deliver_now) - end - - it 'calls mailer action with a CSAT message' do - described_class.new.perform(template_message.id) - expect(mailer).to have_received(:email_reply).with(template_message) - expect(mailer_action).to have_received(:deliver_now) - end - - it 'does not call mailer action with an incoming message' do - described_class.new.perform(incoming_message.id) - expect(mailer).not_to have_received(:email_reply) - expect(mailer_action).not_to have_received(:deliver_now) - end - end - - context 'when emails are not sent' do - before do - allow(ConversationReplyMailer).to receive(:with).and_return(mailer) - allow(mailer).to receive(:email_reply).and_return(mailer_action) - allow(mailer_action).to receive(:deliver_now).and_raise(ArgumentError) - end - - it 'mark message as failed' do - expect { described_class.new.perform(message.id) }.to change { message.reload.status }.from('sent').to('failed') - end - end - end -end