Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Conversations::EventDataPresenter do
|
||||
let(:presenter) { described_class.new(conversation) }
|
||||
let(:conversation) { create(:conversation) }
|
||||
|
||||
describe '#push_data' do
|
||||
let(:expected_data) do
|
||||
{
|
||||
additional_attributes: {},
|
||||
meta: {
|
||||
sender: conversation.contact.push_event_data,
|
||||
assignee: conversation.assigned_entity&.push_event_data,
|
||||
assignee_type: conversation.assignee_type,
|
||||
team: conversation.team&.push_event_data,
|
||||
hmac_verified: conversation.contact_inbox.hmac_verified
|
||||
},
|
||||
id: conversation.display_id,
|
||||
messages: [],
|
||||
labels: [],
|
||||
inbox_id: conversation.inbox_id,
|
||||
status: conversation.status,
|
||||
contact_inbox: conversation.contact_inbox,
|
||||
can_reply: conversation.can_reply?,
|
||||
channel: conversation.inbox.channel_type,
|
||||
timestamp: conversation.last_activity_at.to_i,
|
||||
snoozed_until: conversation.snoozed_until,
|
||||
custom_attributes: conversation.custom_attributes,
|
||||
first_reply_created_at: nil,
|
||||
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
||||
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
||||
created_at: conversation.created_at.to_i,
|
||||
updated_at: conversation.updated_at.to_f,
|
||||
waiting_since: conversation.waiting_since.to_i,
|
||||
priority: nil,
|
||||
unread_count: 0
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns push event payload' do
|
||||
# the exceptions are the values that would be added in enterprise edition.
|
||||
expect(presenter.push_data.except(:applied_sla, :sla_events)).to include(expected_data)
|
||||
end
|
||||
end
|
||||
end
|
||||
15
research/chatwoot/spec/presenters/html_parser_spec.rb
Normal file
15
research/chatwoot/spec/presenters/html_parser_spec.rb
Normal file
@@ -0,0 +1,15 @@
|
||||
require 'rails_helper'
|
||||
RSpec.describe HtmlParser do
|
||||
include ActionMailbox::TestHelper
|
||||
|
||||
describe 'parsed mail decorator' do
|
||||
let(:html_mail) { create_inbound_email_from_fixture('welcome_html.eml').mail }
|
||||
|
||||
it 'parse html content in the mail' do
|
||||
decorated_html_mail = described_class.parse_reply(html_mail.text_part.decoded)
|
||||
expect(decorated_html_mail[0..70]).to eq(
|
||||
"I'm learning English as a first language for the past 13 years, but to "
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
262
research/chatwoot/spec/presenters/mail_presenter_spec.rb
Normal file
262
research/chatwoot/spec/presenters/mail_presenter_spec.rb
Normal file
@@ -0,0 +1,262 @@
|
||||
require 'rails_helper'
|
||||
RSpec.describe MailPresenter do
|
||||
include ActionMailbox::TestHelper
|
||||
|
||||
describe 'parsed mail decorator' do
|
||||
let(:mail) { create_inbound_email_from_fixture('welcome.eml').mail }
|
||||
let(:multiple_in_reply_to_mail) { create_inbound_email_from_fixture('multiple_in_reply_to.eml').mail }
|
||||
let(:mail_without_in_reply_to) { create_inbound_email_from_fixture('reply_cc.eml').mail }
|
||||
let(:html_mail) { create_inbound_email_from_fixture('welcome_html.eml').mail }
|
||||
let(:ascii_mail) { create_inbound_email_from_fixture('non_utf_encoded_mail.eml').mail }
|
||||
let(:decorated_mail) { described_class.new(mail) }
|
||||
|
||||
let(:mail_with_no_subject) { create_inbound_email_from_fixture('mail_with_no_subject.eml').mail }
|
||||
let(:decorated_mail_with_no_subject) { described_class.new(mail_with_no_subject) }
|
||||
|
||||
it 'give default subject line if mail subject is empty' do
|
||||
expect(decorated_mail_with_no_subject.subject).to eq('')
|
||||
end
|
||||
|
||||
it 'give utf8 encoded content' do
|
||||
expect(decorated_mail.subject).to eq("Discussion: Let's debate these attachments")
|
||||
expect(decorated_mail.text_content[:full]).to eq("Let's talk about these images:\n\n")
|
||||
end
|
||||
|
||||
it 'give decoded blob attachments' do
|
||||
decorated_mail.attachments.each do |attachment|
|
||||
expect(attachment.keys).to eq([:original, :blob])
|
||||
expect(attachment[:blob].class.name).to eq('ActiveStorage::Blob')
|
||||
end
|
||||
end
|
||||
|
||||
it 'give number of attachments of the mail' do
|
||||
expect(decorated_mail.number_of_attachments).to eq(2)
|
||||
end
|
||||
|
||||
it 'give the serialized data of the email to be stored in the message' do
|
||||
data = decorated_mail.serialized_data
|
||||
expect(data.keys).to eq([
|
||||
:bcc,
|
||||
:cc,
|
||||
:content_type,
|
||||
:date,
|
||||
:from,
|
||||
:headers,
|
||||
:html_content,
|
||||
:in_reply_to,
|
||||
:message_id,
|
||||
:multipart,
|
||||
:number_of_attachments,
|
||||
:references,
|
||||
:subject,
|
||||
:text_content,
|
||||
:to,
|
||||
:auto_reply
|
||||
])
|
||||
expect(data[:content_type]).to include('multipart/alternative')
|
||||
expect(data[:date].to_s).to eq('2020-04-20T04:20:20-04:00')
|
||||
expect(data[:message_id]).to eq(mail.message_id)
|
||||
expect(data[:multipart]).to be(true)
|
||||
expect(data[:subject]).to eq(decorated_mail.subject)
|
||||
expect(data[:auto_reply]).to eq(decorated_mail.auto_reply?)
|
||||
end
|
||||
|
||||
it 'includes forwarded headers in serialized_data' do
|
||||
mail_with_headers = Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
header['X-Original-From'] = 'Original <original@example.com>'
|
||||
header['X-Original-Sender'] = 'original@example.com'
|
||||
header['X-Forwarded-For'] = 'forwarder@example.com'
|
||||
end
|
||||
|
||||
data = described_class.new(mail_with_headers).serialized_data
|
||||
|
||||
expect(data[:headers]).to eq(
|
||||
'x-original-from' => 'Original <original@example.com>',
|
||||
'x-original-sender' => 'original@example.com',
|
||||
'x-forwarded-for' => 'forwarder@example.com'
|
||||
)
|
||||
end
|
||||
|
||||
it 'returns nil headers when forwarding headers are missing' do
|
||||
mail_without_headers = Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
end
|
||||
|
||||
data = described_class.new(mail_without_headers).serialized_data
|
||||
|
||||
expect(data[:headers]).to be_nil
|
||||
end
|
||||
|
||||
it 'give email from in downcased format' do
|
||||
expect(decorated_mail.from.first.eql?(mail.from.first.downcase)).to be true
|
||||
end
|
||||
|
||||
it 'parse html content in the mail' do
|
||||
decorated_html_mail = described_class.new(html_mail)
|
||||
expect(decorated_html_mail.subject).to eq('Fwd: How good are you in English? How did you improve your English?')
|
||||
expect(decorated_html_mail.text_content[:reply][0..70]).to eq(
|
||||
"I'm learning English as a first language for the past 13 years, but to "
|
||||
)
|
||||
end
|
||||
|
||||
it 'encodes email to UTF-8' do
|
||||
decorated_html_mail = described_class.new(ascii_mail)
|
||||
expect(decorated_html_mail.subject).to eq('أهلين عميلنا الكريم ')
|
||||
expect(decorated_html_mail.text_content[:reply][0..70]).to eq(
|
||||
'أنظروا، أنا أحتاجها فقط لتقوم بالتدقيق في مقالتي الشخصية'
|
||||
)
|
||||
end
|
||||
|
||||
describe '#in_reply_to' do
|
||||
context 'when "in_reply_to" is an array' do
|
||||
it 'returns the first value from the array' do
|
||||
mail_presenter = described_class.new(multiple_in_reply_to_mail)
|
||||
expect(mail_presenter.in_reply_to).to eq('4e6e35f5a38b4_479f13bb90078171@small-app-01.mail')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when "in_reply_to" is not an array' do
|
||||
it 'returns the value as is' do
|
||||
mail_presenter = described_class.new(mail)
|
||||
expect(mail_presenter.in_reply_to).to eq('4e6e35f5a38b4_479f13bb90078178@small-app-01.mail')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when "in_reply_to" is blank' do
|
||||
it 'returns nil' do
|
||||
mail_presenter = described_class.new(mail_without_in_reply_to)
|
||||
expect(mail_presenter.in_reply_to).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#references' do
|
||||
let(:references_mail) { create_inbound_email_from_fixture('references.eml').mail }
|
||||
let(:mail_presenter_with_references) { described_class.new(references_mail) }
|
||||
|
||||
context 'when mail has references' do
|
||||
it 'returns an array of reference IDs' do
|
||||
expect(mail_presenter_with_references.references).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when mail has no references' do
|
||||
it 'returns an empty array' do
|
||||
mail_presenter = described_class.new(mail_without_in_reply_to)
|
||||
expect(mail_presenter.references).to eq([])
|
||||
end
|
||||
end
|
||||
|
||||
context 'when references are included in serialized_data' do
|
||||
it 'includes references in the serialized data' do
|
||||
data = mail_presenter_with_references.serialized_data
|
||||
expect(data[:references]).to eq(['4e6e35f5a38b4_479f13bb90078178@small-app-01.mail', 'test-reference-id'])
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'auto_reply?' do
|
||||
let(:auto_reply_mail) { create_inbound_email_from_fixture('auto_reply.eml').mail }
|
||||
let(:auto_reply_with_auto_submitted_mail) { create_inbound_email_from_fixture('auto_reply_with_auto_submitted.eml').mail }
|
||||
let(:decorated_auto_reply_mail) { described_class.new(auto_reply_mail) }
|
||||
let(:decorated_auto_reply_with_auto_submitted_mail) { described_class.new(auto_reply_with_auto_submitted_mail) }
|
||||
|
||||
it 'returns true for auto-reply emails' do
|
||||
expect(decorated_auto_reply_mail.auto_reply?).to be true
|
||||
expect(decorated_auto_reply_with_auto_submitted_mail.auto_reply?).to be true
|
||||
end
|
||||
|
||||
it 'includes auto_reply status in serialized_data' do
|
||||
expect(decorated_auto_reply_mail.serialized_data[:auto_reply]).to be true
|
||||
expect(decorated_mail.serialized_data[:auto_reply]).to be_falsey
|
||||
end
|
||||
end
|
||||
|
||||
describe 'malformed sender headers' do
|
||||
let(:mail_with_malformed_from) do
|
||||
Mail.new do
|
||||
header['From'] = 'Kevin McDonald <info@example.com'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
end
|
||||
end
|
||||
|
||||
let(:mail_with_malformed_reply_to) do
|
||||
Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
header['Reply-To'] = 'Reply User <reply@example.com'
|
||||
end
|
||||
end
|
||||
|
||||
let(:mail_with_original_sender_header) do
|
||||
Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
header['Reply-To'] = 'Reply User <reply@example.com'
|
||||
header['X-Original-Sender'] = 'Forwarded Sender <forwarded.sender@example.com>'
|
||||
end
|
||||
end
|
||||
|
||||
let(:mail_with_invalid_original_sender_header) do
|
||||
Mail.new do
|
||||
from 'Sender <sender@example.com>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
header['Reply-To'] = 'Reply User <reply@example.com'
|
||||
header['X-Original-Sender'] = 'not an email address'
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns nil sender values when from header is malformed' do
|
||||
presenter = described_class.new(mail_with_malformed_from)
|
||||
|
||||
expect(presenter.original_sender).to be_nil
|
||||
expect(presenter.sender_name).to be_nil
|
||||
expect(presenter.notification_email_from_chatwoot?).to be(false)
|
||||
end
|
||||
|
||||
it 'falls back to from header when reply_to is malformed' do
|
||||
presenter = described_class.new(mail_with_malformed_reply_to)
|
||||
expect(presenter.original_sender).to eq('sender@example.com')
|
||||
end
|
||||
|
||||
it 'uses parsed X-Original-Sender value when available' do
|
||||
presenter = described_class.new(mail_with_original_sender_header)
|
||||
expect(presenter.original_sender).to eq('forwarded.sender@example.com')
|
||||
end
|
||||
|
||||
it 'falls back to from when X-Original-Sender is invalid' do
|
||||
presenter = described_class.new(mail_with_invalid_original_sender_header)
|
||||
expect(presenter.original_sender).to eq('sender@example.com')
|
||||
end
|
||||
|
||||
it 'matches notification sender emails case-insensitively' do
|
||||
mail_with_uppercase_sender = Mail.new do
|
||||
from 'Chatwoot <ACCOUNTS@CHATWOOT.COM>'
|
||||
to 'Inbox <inbox@example.com>'
|
||||
subject :header
|
||||
body 'Hi'
|
||||
end
|
||||
|
||||
with_modified_env MAILER_SENDER_EMAIL: 'Chatwoot <accounts@chatwoot.com>' do
|
||||
presenter = described_class.new(mail_with_uppercase_sender)
|
||||
expect(presenter.notification_email_from_chatwoot?).to be(true)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe MessageContentPresenter do
|
||||
let(:conversation) { create(:conversation) }
|
||||
let(:message) { create(:message, conversation: conversation, content_type: content_type, content: content) }
|
||||
let(:presenter) { described_class.new(message) }
|
||||
|
||||
describe '#outgoing_content' do
|
||||
context 'when message is not input_csat' do
|
||||
let(:content_type) { 'text' }
|
||||
let(:content) { 'Regular message' }
|
||||
|
||||
it 'returns content transformed for channel (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Regular message</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is input_csat and inbox is web widget' do
|
||||
let(:content_type) { 'input_csat' }
|
||||
let(:content) { 'Rate your experience' }
|
||||
|
||||
before do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(true)
|
||||
end
|
||||
|
||||
it 'returns content without survey URL (HTML for WebWidget)' do
|
||||
expect(presenter.outgoing_content).to eq("<p>Rate your experience</p>\n")
|
||||
end
|
||||
end
|
||||
|
||||
context 'when message is input_csat and inbox is not web widget' do
|
||||
let(:content_type) { 'input_csat' }
|
||||
let(:content) { 'Rate your experience' }
|
||||
|
||||
before do
|
||||
allow(message.inbox).to receive(:web_widget?).and_return(false)
|
||||
end
|
||||
|
||||
it 'returns I18n default message when no CSAT config and dynamically generates survey URL (HTML format)' do
|
||||
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expect(presenter.outgoing_content).to include(expected_url)
|
||||
expect(presenter.outgoing_content).to include('<p>')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns CSAT config message when config exists and dynamically generates survey URL (HTML format)' do
|
||||
with_modified_env 'FRONTEND_URL' => 'https://app.chatwoot.com' do
|
||||
allow(message.inbox).to receive(:csat_config).and_return({ 'message' => 'Custom CSAT message' })
|
||||
expected_url = "https://app.chatwoot.com/survey/responses/#{conversation.uuid}"
|
||||
expected_content = "<p>Custom CSAT message #{expected_url}</p>\n"
|
||||
expect(presenter.outgoing_content).to eq(expected_content)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'delegation' do
|
||||
let(:content_type) { 'text' }
|
||||
let(:content) { 'Test message' }
|
||||
|
||||
it 'delegates model methods to the wrapped message' do
|
||||
expect(presenter.content).to eq('Test message')
|
||||
expect(presenter.content_type).to eq('text')
|
||||
expect(presenter.conversation).to eq(conversation)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Messages::SearchDataPresenter do
|
||||
let(:presenter) { described_class.new(message) }
|
||||
let(:account) { create(:account) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact) }
|
||||
let(:message) { create(:message, account: account, inbox: inbox, conversation: conversation, sender: contact) }
|
||||
|
||||
describe '#search_data' do
|
||||
let(:expected_data) do
|
||||
{
|
||||
content: message.content,
|
||||
account_id: message.account_id,
|
||||
inbox_id: message.inbox_id,
|
||||
conversation_id: message.conversation_id,
|
||||
message_type: message.message_type,
|
||||
private: message.private,
|
||||
created_at: message.created_at,
|
||||
source_id: message.source_id,
|
||||
sender_id: message.sender_id,
|
||||
sender_type: message.sender_type,
|
||||
conversation: {
|
||||
id: conversation.display_id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
it 'returns search index payload with core fields' do
|
||||
expect(presenter.search_data).to include(expected_data)
|
||||
end
|
||||
|
||||
context 'with attachments' do
|
||||
before do
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
attachment.meta = { 'transcribed_text' => 'Hello world' }
|
||||
end
|
||||
|
||||
it 'includes attachment transcriptions' do
|
||||
attachments_data = presenter.search_data[:attachments]
|
||||
expect(attachments_data).to be_an(Array)
|
||||
expect(attachments_data.first).to include(transcribed_text: 'Hello world')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with email content attributes' do
|
||||
before do
|
||||
message.update(
|
||||
content_attributes: { email: { subject: 'Test Subject' } }
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes email subject' do
|
||||
content_attrs = presenter.search_data[:content_attributes]
|
||||
expect(content_attrs[:email][:subject]).to eq('Test Subject')
|
||||
end
|
||||
end
|
||||
|
||||
context 'with campaign and automation data' do
|
||||
before do
|
||||
message.update(
|
||||
content_attributes: { 'automation_rule_id' => '456' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes automation_rule_id' do
|
||||
expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,77 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Reports::TimeFormatPresenter do
|
||||
describe '#format' do
|
||||
context 'when formatting days' do
|
||||
it 'formats single day correctly' do
|
||||
expect(described_class.new(86_400).format).to eq '1 day'
|
||||
end
|
||||
|
||||
it 'formats multiple days correctly' do
|
||||
expect(described_class.new(172_800).format).to eq '2 days'
|
||||
end
|
||||
|
||||
it 'includes seconds with days correctly' do
|
||||
expect(described_class.new(86_401).format).to eq '1 day 1 second'
|
||||
end
|
||||
|
||||
it 'includes hours with days correctly' do
|
||||
expect(described_class.new(93_600).format).to eq '1 day 2 hours'
|
||||
end
|
||||
|
||||
it 'includes minutes with days correctly' do
|
||||
expect(described_class.new(86_461).format).to eq '1 day 1 minute'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when formatting hours' do
|
||||
it 'formats single hour correctly' do
|
||||
expect(described_class.new(3600).format).to eq '1 hour'
|
||||
end
|
||||
|
||||
it 'formats multiple hours correctly' do
|
||||
expect(described_class.new(7200).format).to eq '2 hours'
|
||||
end
|
||||
|
||||
it 'includes seconds with hours correctly' do
|
||||
expect(described_class.new(3601).format).to eq '1 hour 1 second'
|
||||
end
|
||||
|
||||
it 'includes minutes with hours correctly' do
|
||||
expect(described_class.new(3660).format).to eq '1 hour 1 minute'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when formatting minutes' do
|
||||
it 'formats single minute correctly' do
|
||||
expect(described_class.new(60).format).to eq '1 minute'
|
||||
end
|
||||
|
||||
it 'formats multiple minutes correctly' do
|
||||
expect(described_class.new(120).format).to eq '2 minutes'
|
||||
end
|
||||
|
||||
it 'includes seconds with minutes correctly' do
|
||||
expect(described_class.new(62).format).to eq '1 minute 2 seconds'
|
||||
end
|
||||
end
|
||||
|
||||
context 'when formatting seconds' do
|
||||
it 'formats multiple seconds correctly' do
|
||||
expect(described_class.new(56).format).to eq '56 seconds'
|
||||
end
|
||||
|
||||
it 'handles floating-point seconds by truncating to the nearest lower second' do
|
||||
expect(described_class.new(55.2).format).to eq '55 seconds'
|
||||
end
|
||||
|
||||
it 'formats single second correctly' do
|
||||
expect(described_class.new(1).format).to eq '1 second'
|
||||
end
|
||||
|
||||
it 'formats nil second correctly' do
|
||||
expect(described_class.new.format).to eq 'N/A'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user