Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
507
research/chatwoot/spec/services/messages/mention_service_spec.rb
Normal file
507
research/chatwoot/spec/services/messages/mention_service_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user