Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,574 @@
require 'rails_helper'
RSpec.describe Messages::MarkdownRendererService, type: :service do
describe '#render' do
context 'when content is blank' do
it 'returns the content as-is for nil' do
result = described_class.new(nil, 'Channel::Whatsapp').render
expect(result).to be_nil
end
it 'returns the content as-is for empty string' do
result = described_class.new('', 'Channel::Whatsapp').render
expect(result).to eq('')
end
end
context 'when channel is Channel::Whatsapp' do
let(:channel_type) { 'Channel::Whatsapp' }
it 'converts bold from double to single asterisk' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'keeps italic with underscore' do
content = '_italic text_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('_italic text_')
end
it 'keeps code with backticks' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('`code`')
end
it 'converts links to URLs only' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('https://example.com')
end
it 'handles combined formatting' do
content = '**bold** _italic_ `code` [link](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold* _italic_ `code` https://example.com')
end
it 'handles nested formatting' do
content = '**bold _italic_**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold _italic_*')
end
it 'preserves unordered list with dash markers' do
content = "- item 1\n- item 2\n- item 3"
result = described_class.new(content, channel_type).render
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'converts asterisk unordered lists to dash markers' do
content = "* item 1\n* item 2\n* item 3"
result = described_class.new(content, channel_type).render
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step\n3. third step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
it 'preserves newlines in plain text without list markers' do
content = "Line 1\nLine 2\nLine 3"
result = described_class.new(content, channel_type).render
expect(result).to include("Line 1\nLine 2\nLine 3")
expect(result).not_to include('Line 1 Line 2')
end
it 'preserves multiple consecutive newlines for spacing' do
content = "Para 1\n\n\n\nPara 2"
result = described_class.new(content, channel_type).render
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Instagram' do
let(:channel_type) { 'Channel::Instagram' }
it 'converts bold from double to single asterisk' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'keeps italic with underscore' do
content = '_italic text_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('_italic text_')
end
it 'strips code backticks' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code')
end
it 'converts links to URLs only' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('https://example.com')
end
it 'preserves bullet list markers' do
content = "- first item\n- second item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'preserves newlines in plain text without list markers' do
content = "Line 1\nLine 2\nLine 3"
result = described_class.new(content, channel_type).render
expect(result).to include("Line 1\nLine 2\nLine 3")
expect(result).not_to include('Line 1 Line 2')
end
it 'preserves multiple consecutive newlines for spacing' do
content = "Para 1\n\n\n\nPara 2"
result = described_class.new(content, channel_type).render
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'renders indented code blocks as plain text preserving exact content' do
content = ' indented code line'
result = described_class.new(content, channel_type).render
expect(result).to eq('indented code line')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::Line' do
let(:channel_type) { 'Channel::Line' }
it 'adds spaces around bold markers' do
content = '**bold**'
result = described_class.new(content, channel_type).render
expect(result).to include(' *bold* ')
end
it 'adds spaces around italic markers' do
content = '_italic_'
result = described_class.new(content, channel_type).render
expect(result).to include(' _italic_ ')
end
it 'adds spaces around code markers' do
content = '`code`'
result = described_class.new(content, channel_type).render
expect(result).to include(' `code` ')
end
end
context 'when channel is Channel::Sms' do
let(:channel_type) { 'Channel::Sms' }
it 'strips all markdown formatting' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('bold italic code')
end
it 'preserves URLs from links in plain text format' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('link text https://example.com')
end
it 'preserves URLs in messages with multiple links' do
content = 'Visit [our site](https://example.com) or [help center](https://help.example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Visit our site https://example.com or help center https://help.example.com')
end
it 'preserves link text and URL when both are present' do
content = '[Reset password](https://example.com/reset)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Reset password https://example.com/reset')
end
it 'handles complex markdown' do
content = "# Heading\n\n**bold** _italic_ [link](https://example.com)"
result = described_class.new(content, channel_type).render
expect(result).to include('Heading')
expect(result).to include('bold')
expect(result).to include('italic')
expect(result).to include('link https://example.com')
expect(result).not_to include('**')
expect(result).not_to include('_')
expect(result).not_to include('[')
end
it 'preserves bullet list markers' do
content = "- first item\n- second item\n- third item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
expect(result).to include('- third item')
end
it 'preserves ordered list markers with numbering' do
content = "1. first step\n2. second step\n3. third step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
it 'preserves newlines in plain text without list markers' do
content = "Line 1\nLine 2\nLine 3"
result = described_class.new(content, channel_type).render
expect(result).to include("Line 1\nLine 2\nLine 3")
expect(result).not_to include('Line 1 Line 2')
end
it 'preserves multiple consecutive newlines for spacing' do
content = "Para 1\n\n\n\nPara 2"
result = described_class.new(content, channel_type).render
expect(result.scan("\n").count).to eq(4)
expect(result).to include("Para 1\n\n\n\nPara 2")
end
end
context 'when channel is Channel::Telegram' do
let(:channel_type) { 'Channel::Telegram' }
it 'converts to HTML format' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
expect(result).to include('<code>code</code>')
end
it 'handles links' do
content = '[link text](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to include('<a href="https://example.com">link text</a>')
end
it 'preserves single newlines' do
content = "line 1\nline 2"
result = described_class.new(content, channel_type).render
expect(result).to include("\n")
expect(result).to include("line 1\nline 2")
end
it 'preserves double newlines (paragraph breaks)' do
content = "para 1\n\npara 2"
result = described_class.new(content, channel_type).render
expect(result.scan("\n").count).to eq(2)
expect(result).to include("para 1\n\npara 2")
end
it 'preserves multiple consecutive newlines' do
content = "para 1\n\n\n\npara 2"
result = described_class.new(content, channel_type).render
expect(result.scan("\n").count).to eq(4)
expect(result).to include("para 1\n\n\n\npara 2")
end
it 'preserves newlines with varying amounts of whitespace between them' do
# Test with 1 space, 3 spaces, 5 spaces, and tabs to ensure it handles any amount of whitespace
content = "hello\n \n \n \n\t\nworld"
result = described_class.new(content, channel_type).render
# Whitespace-only lines are normalized, so we should have at least 5 newlines preserved
expect(result.scan("\n").count).to be >= 5
expect(result).to include('hello')
expect(result).to include('world')
# Should not collapse to just 1-2 newlines
expect(result.scan("\n").count).to be > 3
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
it 'converts blockquotes to HTML' do
content = '> quoted text'
result = described_class.new(content, channel_type).render
expect(result).to include('<blockquote>')
expect(result).to include('quoted text')
end
end
context 'when channel is Channel::Email' do
let(:channel_type) { 'Channel::Email' }
it 'renders full HTML' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
end
it 'renders ordered lists as HTML' do
content = "1. first\n2. second"
result = described_class.new(content, channel_type).render
expect(result).to include('<ol>')
expect(result).to include('<li>first</li>')
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
end
context 'when channel is Channel::WebWidget' do
let(:channel_type) { 'Channel::WebWidget' }
it 'renders full HTML like Email' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to include('<strong>bold</strong>')
expect(result).to include('<em>italic</em>')
expect(result).to include('<code>code</code>')
end
it 'converts strikethrough to HTML' do
content = '~~strikethrough text~~'
result = described_class.new(content, channel_type).render
expect(result).to include('<del>strikethrough text</del>')
end
end
context 'when channel is Channel::FacebookPage' do
let(:channel_type) { 'Channel::FacebookPage' }
it 'converts bold to single asterisk like Instagram' do
content = '**bold text**'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('*bold text*')
end
it 'strips unsupported formatting' do
content = '`code` ~~strike~~'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code ~~strike~~')
end
it 'preserves bullet list markers like Instagram' do
content = "- first item\n- second item"
result = described_class.new(content, channel_type).render
expect(result).to include('- first item')
expect(result).to include('- second item')
end
it 'preserves ordered list markers with numbering like Instagram' do
content = "1. first step\n2. second step"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
end
it 'renders code blocks as plain text' do
content = "```\ncode here\n```"
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('code here')
end
it 'handles code blocks with emojis and special characters without stack overflow' do
content = " first line\n 🌐 second line\n"
result = described_class.new(content, channel_type).render
expect(result).to eq("first line\n🌐 second line")
end
end
context 'when channel is Channel::TwilioSms' do
let(:channel_type) { 'Channel::TwilioSms' }
it 'strips all markdown like SMS when medium is sms' do
content = '**bold** _italic_'
channel = instance_double(Channel::TwilioSms, whatsapp?: false)
result = described_class.new(content, channel_type, channel).render
expect(result.strip).to eq('bold italic')
end
it 'uses WhatsApp renderer when medium is whatsapp' do
content = '**bold** _italic_ [link](https://example.com)'
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result.strip).to eq('*bold* _italic_ https://example.com')
end
it 'preserves newlines in Twilio WhatsApp' do
content = "Line 1\nLine 2\nLine 3"
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result).to include("Line 1\nLine 2\nLine 3")
end
it 'preserves ordered list markers with numbering in Twilio WhatsApp' do
content = "1. first step\n2. second step\n3. third step"
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result).to include('1. first step')
expect(result).to include('2. second step')
expect(result).to include('3. third step')
end
it 'preserves unordered list markers in Twilio WhatsApp' do
content = "- item 1\n- item 2\n- item 3"
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content, channel_type, channel).render
expect(result).to include('- item 1')
expect(result).to include('- item 2')
expect(result).to include('- item 3')
end
it 'backwards compatible when channel is not provided' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result.strip).to eq('bold italic')
end
end
context 'when channel is Channel::Api' do
let(:channel_type) { 'Channel::Api' }
it 'preserves markdown as-is' do
content = '**bold** _italic_ `code`'
result = described_class.new(content, channel_type).render
expect(result).to eq('**bold** _italic_ `code`')
end
it 'preserves links with markdown syntax' do
content = '[Click here](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to eq('[Click here](https://example.com)')
end
it 'preserves lists with markdown syntax' do
content = "- Item 1\n- Item 2"
result = described_class.new(content, channel_type).render
expect(result).to eq("- Item 1\n- Item 2")
end
end
context 'when channel is Channel::TwitterProfile' do
let(:channel_type) { 'Channel::TwitterProfile' }
it 'strips all markdown like SMS' do
content = '**bold** [link](https://example.com)'
result = described_class.new(content, channel_type).render
expect(result).to include('bold')
expect(result).to include('link https://example.com')
expect(result).not_to include('**')
expect(result).not_to include('[')
end
it 'preserves URLs from links' do
content = '[Reset password](https://example.com/reset)'
result = described_class.new(content, channel_type).render
expect(result).to eq('Reset password https://example.com/reset')
end
end
context 'when testing all formatting types' do
let(:channel_type) { 'Channel::Whatsapp' }
it 'handles ordered lists with proper numbering' do
content = "1. first\n2. second\n3. third"
result = described_class.new(content, channel_type).render
expect(result).to include('1. first')
expect(result).to include('2. second')
expect(result).to include('3. third')
end
end
context 'when channel is unknown' do
let(:channel_type) { 'Channel::Unknown' }
it 'returns content as-is' do
content = '**bold** _italic_'
result = described_class.new(content, channel_type).render
expect(result).to eq(content)
end
end
# Shared test for all text-based channels that preserve multiple newlines
# This tests the real-world scenario where frontend sends newlines with whitespace between them
context 'when content has multiple newlines with whitespace between them' do
# This mimics what frontends often send: newlines with spaces/tabs between them
let(:content_with_whitespace_newlines) { "hello \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n \n\nhello wow" }
%w[
Channel::Telegram
Channel::Whatsapp
Channel::Instagram
Channel::FacebookPage
Channel::Line
Channel::Sms
].each do |channel_type|
context "when channel is #{channel_type}" do
it 'normalizes whitespace-only lines and preserves multiple newlines' do
result = described_class.new(content_with_whitespace_newlines, channel_type).render
# Should preserve most of the newlines (at least 10+)
# The exact count may vary slightly by renderer, but should be significantly more than 1-2
expect(result.scan("\n").count).to be >= 10
# Should not collapse everything to just 1-2 newlines
expect(result.scan("\n").count).to be > 5
end
end
end
context 'when channel is Channel::TwilioSms with WhatsApp' do
it 'normalizes whitespace-only lines and preserves multiple newlines' do
channel = instance_double(Channel::TwilioSms, whatsapp?: true)
result = described_class.new(content_with_whitespace_newlines, 'Channel::TwilioSms', channel).render
expect(result.scan("\n").count).to be >= 10
end
end
end
end
end

View File

@@ -0,0 +1,507 @@
require 'rails_helper'
describe Messages::MentionService do
let!(:account) { create(:account) }
let!(:user) { create(:user, account: account) }
let!(:first_agent) { create(:user, account: account) }
let!(:second_agent) { create(:user, account: account) }
let!(:third_agent) { create(:user, account: account) }
let!(:admin_user) { create(:user, account: account, role: :administrator) }
let!(:inbox) { create(:inbox, account: account) }
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
let!(:team) { create(:team, account: account, name: 'Support Team') }
let!(:empty_team) { create(:team, account: account, name: 'Empty Team') }
let(:builder) { double }
before do
create(:inbox_member, user: first_agent, inbox: inbox)
create(:inbox_member, user: second_agent, inbox: inbox)
create(:team_member, user: first_agent, team: team)
create(:team_member, user: second_agent, team: team)
conversation.reload
allow(NotificationBuilder).to receive(:new).and_return(builder)
allow(builder).to receive(:perform)
allow(Conversations::UserMentionJob).to receive(:perform_later)
end
describe '#perform' do
context 'when message is not private' do
it 'does not process mentions for public messages' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: false
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when message has no content' do
it 'does not process mentions for empty messages' do
message = build(
:message,
conversation: conversation,
account: account,
content: nil,
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when message has no mentions' do
it 'does not process messages without mentions' do
message = build(
:message,
conversation: conversation,
account: account,
content: 'just a regular message',
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
end
describe 'user mentions' do
context 'when message contains single user mention' do
it 'creates notifications for inbox member who was mentioned' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
[first_agent.id.to_s],
conversation.id,
account.id
)
end
it 'adds mentioned user as conversation participant' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to include(first_agent.id)
end
end
context 'when message contains multiple user mentions' do
let(:message) do
build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{second_agent.id}/#{second_agent.name}) " \
"and (mention://user/#{first_agent.id}/#{first_agent.name}), please look into this?",
private: true
)
end
it 'creates notifications for all mentioned inbox members' do
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
it 'adds all mentioned users to the participants list' do
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
end
it 'passes unique user IDs to UserMentionJob' do
described_class.new(message: message).perform
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
context 'when mentioned user is not an inbox member' do
let!(:non_member_user) { create(:user, account: account) }
it 'does not create notifications for non-inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{non_member_user.id}/#{non_member_user.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when mentioned user is an admin' do
it 'creates notifications for admin users even if not inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{admin_user.id}/#{admin_user.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: admin_user,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when same user is mentioned multiple times' do
it 'creates only one notification per user' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hi (mention://user/#{first_agent.id}/#{first_agent.name}) and again (mention://user/#{first_agent.id}/#{first_agent.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).once
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
[first_agent.id.to_s],
conversation.id,
account.id
)
end
end
end
describe 'team mentions' do
context 'when message contains single team mention' do
it 'creates notifications for all team members who are inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
it 'adds all team members as conversation participants' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(conversation.conversation_participants.map(&:user_id)).to contain_exactly(first_agent.id, second_agent.id)
end
it 'passes team member IDs to UserMentionJob' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
context 'when team has members who are not inbox members' do
let!(:non_inbox_team_member) { create(:user, account: account) }
before do
create(:team_member, user: non_inbox_team_member, team: team)
end
it 'only notifies team members who are also inbox members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: first_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: second_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).not_to have_received(:new).with(
notification_type: 'conversation_mention', user: non_inbox_team_member, account: account,
primary_actor: message.conversation, secondary_actor: message
)
end
end
context 'when team has admin members' do
before do
create(:team_member, user: admin_user, team: team)
end
it 'includes admin team members in notifications' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: admin_user,
account: account,
primary_actor: message.conversation,
secondary_actor: message
)
end
end
context 'when team is empty' do
it 'does not create any notifications for empty teams' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{empty_team.id}/#{empty_team.name}) please help",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when team does not exist' do
it 'does not create notifications for non-existent teams' do
message = build(
:message,
conversation: conversation,
account: account,
content: 'hey (mention://team/99999/NonExistentTeam) please help',
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
context 'when same team is mentioned multiple times' do
it 'creates only one notification per team member' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{team.id}/#{team.name}) and again (mention://team/#{team.id}/#{team.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).exactly(2).times
expect(Conversations::UserMentionJob).to have_received(:perform_later).with(
contain_exactly(first_agent.id.to_s, second_agent.id.to_s),
conversation.id,
account.id
)
end
end
end
describe 'mixed user and team mentions' do
context 'when message contains both user and team mentions' do
it 'creates notifications for both individual users and team members' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{third_agent.id}/#{third_agent.name}) and (mention://team/#{team.id}/#{team.name})",
private: true
)
# Make third_agent an inbox member
create(:inbox_member, user: third_agent, inbox: inbox)
described_class.new(message: message).perform
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: third_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: first_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention', user: second_agent, account: account,
primary_actor: message.conversation, secondary_actor: message
)
end
it 'avoids duplicate notifications when user is mentioned directly and via team' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{first_agent.id}/#{first_agent.name}) and (mention://team/#{team.id}/#{team.name})",
private: true
)
described_class.new(message: message).perform
# first_agent should only receive one notification despite being mentioned directly and via team
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: first_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).once
expect(NotificationBuilder).to have_received(:new).with(
notification_type: 'conversation_mention',
user: second_agent,
account: account,
primary_actor: message.conversation,
secondary_actor: message
).once
end
end
end
describe 'cross-account validation' do
let!(:other_account) { create(:account) }
let!(:other_team) { create(:team, account: other_account) }
let!(:other_user) { create(:user, account: other_account) }
before do
create(:team_member, user: other_user, team: other_team)
end
it 'does not process mentions for teams from other accounts' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://team/#{other_team.id}/#{other_team.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
it 'does not process mentions for users from other accounts' do
message = build(
:message,
conversation: conversation,
account: account,
content: "hey (mention://user/#{other_user.id}/#{other_user.name})",
private: true
)
described_class.new(message: message).perform
expect(NotificationBuilder).not_to have_received(:new)
expect(Conversations::UserMentionJob).not_to have_received(:perform_later)
end
end
end

View File

@@ -0,0 +1,109 @@
require 'rails_helper'
describe Messages::NewMessageNotificationService do
context 'when message is not notifiable' do
it 'will not create any notifications' do
message = build(:message, message_type: :activity)
expect(NotificationBuilder).not_to receive(:new)
described_class.new(message: message).perform
end
end
context 'when message is notifiable' do
let(:account) { create(:account) }
let(:assignee) { create(:user, account: account) }
let(:participating_agent_1) { create(:user, account: account) }
let(:participating_agent_2) { create(:user, account: account) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: assignee) }
before do
create(:inbox_member, inbox: inbox, user: participating_agent_1)
create(:inbox_member, inbox: inbox, user: participating_agent_2)
create(:inbox_member, inbox: inbox, user: assignee)
create(:conversation_participant, conversation: conversation, user: participating_agent_1)
create(:conversation_participant, conversation: conversation, user: participating_agent_2)
create(:conversation_participant, conversation: conversation, user: assignee)
end
context 'when message is created by a participant' do
let(:message) { create(:message, conversation: conversation, account: account, sender: participating_agent_1) }
before do
described_class.new(message: message).perform
end
it 'creates notifications for other participating users' do
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'creates notifications for assignee' do
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'will not create notifications for the user who created the message' do
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
context 'when message is created by a contact' do
let(:message) { create(:message, conversation: conversation, account: account) }
before do
described_class.new(message: message).perform
end
it 'creates notifications for assignee' do
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message', account: account,
primary_actor: message.conversation, secondary_actor: message)).to exist
end
it 'creates notifications for all participating users' do
expect(participating_agent_1.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
expect(participating_agent_2.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
end
end
context 'when multiple notification conditions are met' do
let(:message) { create(:message, conversation: conversation, account: account) }
before do
described_class.new(message: message).perform
end
it 'will not create participating notifications for the assignee if assignee notification was send' do
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).to exist
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
context 'when message is created by assignee' do
let(:message) { create(:message, conversation: conversation, account: account, sender: assignee) }
before do
described_class.new(message: message).perform
end
it 'will not create notifications for the user who created the message' do
expect(assignee.notifications.where(notification_type: 'participating_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
expect(assignee.notifications.where(notification_type: 'assigned_conversation_new_message',
account: account, primary_actor: message.conversation,
secondary_actor: message)).not_to exist
end
end
end
end

View File

@@ -0,0 +1,192 @@
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 account email rate limit is exceeded' 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_any_instance_of(Account).to receive(:within_email_rate_limit?).and_return(false) # rubocop:disable RSpec/AnyInstance
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

View File

@@ -0,0 +1,46 @@
require 'rails_helper'
describe Messages::StatusUpdateService do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, account: account) }
describe '#perform' do
context 'when status is valid' do
it 'updates the status of the message' do
service = described_class.new(message, 'delivered')
service.perform
expect(message.reload.status).to eq('delivered')
end
it 'clears external_error when status is not failed' do
message.update!(status: 'failed', external_error: 'previous error')
service = described_class.new(message, 'delivered')
service.perform
expect(message.reload.status).to eq('delivered')
expect(message.reload.external_error).to be_nil
end
it 'updates external_error when status is failed' do
service = described_class.new(message, 'failed', 'some error')
service.perform
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('some error')
end
end
context 'when status is invalid' do
it 'returns false for invalid status' do
service = described_class.new(message, 'invalid_status')
expect(service.perform).to be false
end
it 'prevents transition from read to delivered' do
message.update!(status: 'read')
service = described_class.new(message, 'delivered')
expect(service.perform).to be false
expect(message.reload.status).to eq('read')
end
end
end
end