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

View File

@@ -0,0 +1,276 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Account do
it { is_expected.to have_many(:users).through(:account_users) }
it { is_expected.to have_many(:account_users) }
it { is_expected.to have_many(:inboxes).dependent(:destroy_async) }
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
it { is_expected.to have_many(:contacts).dependent(:destroy_async) }
it { is_expected.to have_many(:canned_responses).dependent(:destroy_async) }
it { is_expected.to have_many(:facebook_pages).class_name('::Channel::FacebookPage').dependent(:destroy_async) }
it { is_expected.to have_many(:web_widgets).class_name('::Channel::WebWidget').dependent(:destroy_async) }
it { is_expected.to have_many(:webhooks).dependent(:destroy_async) }
it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) }
it { is_expected.to have_many(:reporting_events) }
it { is_expected.to have_many(:portals).dependent(:destroy_async) }
it { is_expected.to have_many(:categories).dependent(:destroy_async) }
it { is_expected.to have_many(:teams).dependent(:destroy_async) }
# This validation happens in ApplicationRecord
describe 'length validations' do
let(:account) { create(:account) }
it 'validates name presence' do
account.name = ''
account.valid?
expect(account.errors[:name]).to include("can't be blank")
end
it 'validates name length' do
account.name = 'a' * 256
account.valid?
expect(account.errors[:name]).to include('is too long (maximum is 255 characters)')
end
it 'validates domain length' do
account.domain = 'a' * 150
account.valid?
expect(account.errors[:domain]).to include('is too long (maximum is 100 characters)')
end
end
describe 'usage_limits' do
let(:account) { create(:account) }
it 'returns ChatwootApp.max limits' do
expect(account.usage_limits[:agents]).to eq(ChatwootApp.max_limit)
expect(account.usage_limits[:inboxes]).to eq(ChatwootApp.max_limit)
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
it 'returns the domain from inbox if inbox value is present' do
account.update(domain: 'test.com')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test2.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is nil' do
account.update(domain: nil)
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
it 'returns the domain from ENV if inbox value is empty string' do
account.update(domain: '')
with_modified_env MAILER_INBOUND_EMAIL_DOMAIN: 'test.com' do
expect(account.inbound_email_domain).to eq('test.com')
end
end
end
describe 'support_email' do
let(:account) { create(:account) }
it 'returns the support email from inbox if inbox value is present' do
account.update(support_email: 'support@chatwoot.com')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('support@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is nil' do
account.update(support_email: nil)
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
it 'returns the support email from ENV if inbox value is empty string' do
account.update(support_email: '')
with_modified_env MAILER_SENDER_EMAIL: 'hello@chatwoot.com' do
expect(account.support_email).to eq('hello@chatwoot.com')
end
end
end
context 'when after_destroy is called' do
it 'conv_dpid_seq and camp_dpid_seq_ are deleted' do
account = create(:account)
query = "select * from information_schema.sequences where sequence_name in ('camp_dpid_seq_#{account.id}', 'conv_dpid_seq_#{account.id}');"
expect(ActiveRecord::Base.connection.execute(query).count).to eq(2)
expect(account.locale).to eq('en')
account.destroy
expect(ActiveRecord::Base.connection.execute(query).count).to eq(0)
end
end
describe 'locale' do
it 'returns correct language if the value is set' do
account = create(:account, locale: 'fr')
expect(account.locale).to eq('fr')
expect(account.locale_english_name).to eq('french')
end
it 'returns english if the value is not set' do
account = create(:account, locale: nil)
expect(account.locale).to be_nil
expect(account.locale_english_name).to eq('english')
end
it 'returns english if the value is empty string' do
account = create(:account, locale: '')
expect(account.locale).to be_nil
expect(account.locale_english_name).to eq('english')
end
it 'returns correct language if the value has country code' do
account = create(:account, locale: 'pt_BR')
expect(account.locale).to eq('pt_BR')
expect(account.locale_english_name).to eq('portuguese')
end
end
describe 'settings' do
let(:account) { create(:account) }
context 'when auto_resolve_after' do
it 'validates minimum value' do
account.settings = { auto_resolve_after: 4 }
expect(account).to be_invalid
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be greater than or equal to 10'] })
end
it 'validates maximum value' do
account.settings = { auto_resolve_after: 1_439_857 }
expect(account).to be_invalid
expect(account.errors.messages).to eq({ auto_resolve_after: ['must be less than or equal to 1439856'] })
end
it 'allows valid values' do
account.settings = { auto_resolve_after: 15 }
expect(account).to be_valid
account.settings = { auto_resolve_after: 1_439_856 }
expect(account).to be_valid
end
it 'allows null values' do
account.settings = { auto_resolve_after: nil }
expect(account).to be_valid
end
end
context 'when auto_resolve_message' do
it 'allows string values' do
account.settings = { auto_resolve_message: 'This conversation has been resolved automatically.' }
expect(account).to be_valid
end
it 'allows empty string' do
account.settings = { auto_resolve_message: '' }
expect(account).to be_valid
end
it 'allows nil values' do
account.settings = { auto_resolve_message: nil }
expect(account).to be_valid
end
end
context 'when using store_accessor' do
it 'correctly gets and sets auto_resolve_after' do
account.auto_resolve_after = 30
expect(account.auto_resolve_after).to eq(30)
expect(account.settings['auto_resolve_after']).to eq(30)
end
it 'correctly gets and sets auto_resolve_message' do
message = 'This conversation was automatically resolved'
account.auto_resolve_message = message
expect(account.auto_resolve_message).to eq(message)
expect(account.settings['auto_resolve_message']).to eq(message)
end
it 'handles nil values correctly' do
account.auto_resolve_after = nil
account.auto_resolve_message = nil
expect(account.auto_resolve_after).to be_nil
expect(account.auto_resolve_message).to be_nil
end
end
context 'when using with_auto_resolve scope' do
it 'finds accounts with auto_resolve_after set' do
account.update(auto_resolve_after: 40 * 24 * 60)
expect(described_class.with_auto_resolve.pluck(:id)).to include(account.id)
end
it 'does not find accounts without auto_resolve_after' do
account.update(auto_resolve_after: nil)
expect(described_class.with_auto_resolve.pluck(:id)).not_to include(account.id)
end
end
end
describe 'captain_preferences' do
let(:account) { create(:account) }
describe 'with no saved preferences' do
it 'returns defaults from llm.yml' do
prefs = account.captain_preferences
expect(prefs[:features].values).to all(be false)
Llm::Models.feature_keys.each do |feature|
expect(prefs[:models][feature]).to eq(Llm::Models.default_model_for(feature))
end
end
end
describe 'with saved model preferences' do
it 'returns saved preferences merged with defaults' do
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
prefs = account.captain_preferences
expect(prefs[:models]['editor']).to eq('gpt-4.1-mini')
expect(prefs[:models]['assistant']).to eq('gpt-5.2')
expect(prefs[:models]['copilot']).to eq(Llm::Models.default_model_for('copilot'))
end
end
describe 'with saved feature preferences' do
it 'returns saved feature states' do
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
prefs = account.captain_preferences
expect(prefs[:features]['editor']).to be true
expect(prefs[:features]['assistant']).to be true
expect(prefs[:features]['copilot']).to be false
end
end
describe 'validation' do
it 'rejects invalid model for a feature' do
account.captain_models = { 'label_suggestion' => 'gpt-5.1' }
expect(account).not_to be_valid
expect(account.errors[:captain_models].first).to include('not a valid model for label_suggestion')
end
it 'accepts valid model for a feature' do
account.captain_models = { 'editor' => 'gpt-4.1-mini', 'label_suggestion' => 'gpt-4.1-nano' }
expect(account).to be_valid
end
end
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe AccountUser do
include ActiveJob::TestHelper
let!(:account_user) { create(:account_user) }
let!(:inbox) { create(:inbox, account: account_user.account) }
describe 'notification_settings' do
it 'gets created with the right default settings' do
expect(account_user.user.notification_settings).not_to be_nil
expect(account_user.user.notification_settings.first.email_conversation_creation?).to be(false)
expect(account_user.user.notification_settings.first.email_conversation_assignment?).to be(true)
end
end
describe 'permissions' do
it 'returns the right permissions' do
expect(account_user.permissions).to eq(['agent'])
end
it 'returns the right permissions for administrator' do
account_user.administrator!
expect(account_user.permissions).to eq(['administrator'])
end
end
describe 'destroy call agent::destroy service' do
it 'gets created with the right default settings' do
create(:conversation, account: account_user.account, assignee: account_user.user, inbox: inbox)
user = account_user.user
expect(user.assigned_conversations.count).to eq(1)
perform_enqueued_jobs do
account_user.destroy!
end
expect(user.assigned_conversations.count).to eq(0)
end
end
end

View File

@@ -0,0 +1,13 @@
require 'rails_helper'
RSpec.describe AgentBotInbox do
describe 'validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:agent_bot_id) }
end
describe 'associations' do
it { is_expected.to belong_to(:agent_bot) }
it { is_expected.to belong_to(:inbox) }
end
end

View File

@@ -0,0 +1,61 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe AgentBot do
describe 'associations' do
it { is_expected.to have_many(:agent_bot_inboxes) }
it { is_expected.to have_many(:inboxes) }
end
describe 'concerns' do
it_behaves_like 'access_tokenable'
it_behaves_like 'avatarable'
end
context 'when it validates outgoing_url length' do
let(:agent_bot) { create(:agent_bot) }
it 'valid when within limit' do
agent_bot.outgoing_url = 'a' * Limits::URL_LENGTH_LIMIT
expect(agent_bot.valid?).to be true
end
it 'invalid when crossed the limit' do
agent_bot.outgoing_url = 'a' * (Limits::URL_LENGTH_LIMIT + 1)
agent_bot.valid?
expect(agent_bot.errors[:outgoing_url]).to include("is too long (maximum is #{Limits::URL_LENGTH_LIMIT} characters)")
end
end
context 'when agent bot is deleted' do
let(:agent_bot) { create(:agent_bot) }
let(:message) { create(:message, sender: agent_bot) }
it 'nullifies the message sender key' do
expect(message.sender).to eq agent_bot
agent_bot.destroy!
expect(message.reload.sender).to be_nil
end
end
describe '#system_bot?' do
context 'when account_id is nil' do
let(:agent_bot) { create(:agent_bot, account_id: nil) }
it 'returns true' do
expect(agent_bot.system_bot?).to be true
end
end
context 'when account_id is present' do
let(:account) { create(:account) }
let(:agent_bot) { create(:agent_bot, account: account) }
it 'returns false' do
expect(agent_bot.system_bot?).to be false
end
end
end
end

View File

@@ -0,0 +1,113 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ApplicationRecord do
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :smtp_password,
value: 'smtp-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_email,
attribute: :imap_password,
value: 'imap-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twilio_sms,
attribute: :auth_token,
value: 'twilio-secret'
it_behaves_like 'encrypted external credential',
factory: :integrations_hook,
attribute: :access_token,
value: 'hook-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :page_access_token,
value: 'fb-page-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_facebook_page,
attribute: :user_access_token,
value: 'fb-user-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_instagram,
attribute: :access_token,
value: 'ig-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_secret,
value: 'line-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_line,
attribute: :line_channel_token,
value: 'line-token-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_telegram,
attribute: :bot_token,
value: 'telegram-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token,
value: 'twitter-access-secret'
it_behaves_like 'encrypted external credential',
factory: :channel_twitter_profile,
attribute: :twitter_access_token_secret,
value: 'twitter-secret-secret'
context 'when backfilling legacy plaintext' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'reads existing plaintext and encrypts on update' do
account = create(:account)
channel = create(:channel_email, account: account, smtp_password: nil)
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_email SET smtp_password = ? WHERE id = ?', 'legacy-plain', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
legacy_record = Channel::Email.find(channel.id)
expect(legacy_record.smtp_password).to eq('legacy-plain')
legacy_record.update!(smtp_password: 'encrypted-now')
stored_value = legacy_record.reload.read_attribute_before_type_cast(:smtp_password)
expect(stored_value).to be_present
expect(stored_value).not_to include('encrypted-now')
expect(legacy_record.smtp_password).to eq('encrypted-now')
end
end
context 'when looking up telegram legacy records' do
before do
skip('encryption keys missing; see run_mfa_spec workflow') unless Chatwoot.encryption_configured?
end
it 'finds plaintext records via fallback lookup' do
channel = create(:channel_telegram, bot_token: 'legacy-token')
# Simulate legacy plaintext by updating the DB directly
sql = ActiveRecord::Base.send(
:sanitize_sql_array,
['UPDATE channel_telegram SET bot_token = ? WHERE id = ?', 'legacy-token', channel.id]
)
ActiveRecord::Base.connection.execute(sql)
found = Channel::Telegram.find_by(bot_token: 'legacy-token')
expect(found).to eq(channel)
end
end
end

View File

@@ -0,0 +1,192 @@
require 'rails_helper'
RSpec.describe Article do
let!(:account) { create(:account) }
let(:user) { create(:user, account_ids: [account.id], role: :agent) }
let!(:portal_1) { create(:portal, account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category_1) { create(:category, slug: 'category_1', locale: 'en', portal_id: portal_1.id) }
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:author_id) }
it { is_expected.to validate_presence_of(:title) }
it { is_expected.to validate_presence_of(:content) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:author) }
end
# This validation happens in ApplicationRecord
describe 'length validations' do
let(:article) do
create(:article, category_id: category_1.id, content: 'This is the content', description: 'this is the description',
slug: 'this-is-title', title: 'this is title',
portal_id: portal_1.id, author_id: user.id)
end
context 'when it validates content length' do
it 'valid when within limit' do
article.content = 'a' * 1000
expect(article.valid?).to be true
end
it 'invalid when crossed the limit' do
article.content = 'a' * 25_001
article.valid?
expect(article.errors[:content]).to include('is too long (maximum is 20000 characters)')
end
end
end
describe 'add_locale_to_article' do
let(:portal) { create(:portal, config: { allowed_locales: %w[en es pt], default_locale: 'es' }) }
let(:category) { create(:category, slug: 'category_1', locale: 'pt', portal_id: portal.id) }
it 'adds locale to article from category' do
article = create(:article, category_id: category.id, content: 'This is the content', description: 'this is the description',
slug: 'this-is-title', title: 'this is title',
portal_id: portal.id, author_id: user.id)
expect(article.locale).to eq(category.locale)
end
it 'adds locale to article from portal' do
article = create(:article, content: 'This is the content', description: 'this is the description',
slug: 'this-is-title', title: 'this is title',
portal_id: portal.id, author_id: user.id, locale: '')
expect(article.locale).to eq(portal.default_locale)
end
end
describe 'search' do
let!(:portal_2) { create(:portal, account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:category_2) { create(:category, slug: 'category_2', locale: 'es', portal_id: portal_1.id) }
let!(:category_3) { create(:category, slug: 'category_3', locale: 'es', portal_id: portal_2.id) }
before do
create(:article, category_id: category_1.id, content: 'This is the content', description: 'this is the description',
slug: 'this-is-title', title: 'this is title',
portal_id: portal_1.id, author_id: user.id)
create(:article, category_id: category_1.id, slug: 'title-1', title: 'title 1', content: 'This is the content', portal_id: portal_1.id,
author_id: user.id)
create(:article, category_id: category_2.id, slug: 'title-2', title: 'title 2', portal_id: portal_2.id, author_id: user.id)
create(:article, category_id: category_2.id, slug: 'title-3', title: 'title 3', portal_id: portal_1.id, author_id: user.id)
create(:article, category_id: category_3.id, slug: 'title-6', title: 'title 6', portal_id: portal_2.id, author_id: user.id, status: :published)
create(:article, category_id: category_2.id, slug: 'title-7', title: 'title 7', portal_id: portal_1.id, author_id: user.id, status: :published)
end
context 'when no parameters passed' do
it 'returns all the articles in portal' do
records = portal_1.articles.search({})
expect(records.count).to eq(portal_1.articles.count)
records = portal_2.articles.search({})
expect(records.count).to eq(portal_2.articles.count)
end
end
context 'when params passed' do
it 'returns all the articles with all the params filters' do
params = { query: 'title', locale: 'es', category_slug: 'category_3' }
records = portal_2.articles.search(params)
expect(records.count).to eq(1)
params = { query: 'this', locale: 'en', category_slug: 'category_1' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
params = { status: 'published' }
records = portal_1.articles.search(params)
expect(records.count).to eq(portal_1.articles.published.size)
end
end
context 'when some params missing' do
it 'returns data with category slug' do
params = { category_slug: 'category_2' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'returns data with locale' do
params = { locale: 'es' }
records = portal_2.articles.search(params)
expect(records.count).to eq(2)
params = { locale: 'en' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'returns data with text_search query' do
params = { query: 'title' }
records = portal_2.articles.search(params)
expect(records.count).to eq(2)
params = { query: 'title' }
records = portal_1.articles.search(params)
expect(records.count).to eq(4)
params = { query: 'the content' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'returns data with text_search query and locale' do
params = { query: 'title', locale: 'es' }
records = portal_2.articles.search(params)
expect(records.count).to eq(2)
end
it 'returns records with locale and category_slug' do
params = { category_slug: 'category_2', locale: 'es' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'return records with category_slug and text_search query' do
params = { category_slug: 'category_2', query: 'title' }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'returns records with author and category_slug' do
params = { category_slug: 'category_2', author_id: user.id }
records = portal_1.articles.search(params)
expect(records.count).to eq(2)
end
it 'auto saves article slug' do
article = create(:article, category_id: category_1.id, title: 'the awesome article 1', content: 'This is the content', portal_id: portal_1.id,
author_id: user.id)
expect(article.slug).to include('the-awesome-article-1')
end
end
end
describe '#to_llm_text' do
it 'returns formatted article text' do
category = create(:category, name: 'Test Category', slug: 'test_category', portal_id: portal_1.id)
article = create(:article, title: 'Test Article', category_id: category.id, content: 'This is the content', portal_id: portal_1.id,
author_id: user.id)
expected_output = <<~TEXT
Title: #{article.title}
ID: #{article.id}
Status: #{article.status}
Category: #{category.name}
Author: #{user.name}
Views: #{article.views}
Created At: #{article.created_at}
Updated At: #{article.updated_at}
Content:
#{article.content}
TEXT
expect(article.to_llm_text).to eq(expected_output)
end
end
end

View File

@@ -0,0 +1,56 @@
require 'rails_helper'
RSpec.describe AssignmentPolicy do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:inbox_assignment_policies).dependent(:destroy) }
it { is_expected.to have_many(:inboxes).through(:inbox_assignment_policies) }
end
describe 'validations' do
subject { build(:assignment_policy) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:account_id) }
end
describe 'fair distribution validations' do
it 'requires fair_distribution_limit to be greater than 0' do
policy = build(:assignment_policy, fair_distribution_limit: 0)
expect(policy).not_to be_valid
expect(policy.errors[:fair_distribution_limit]).to include('must be greater than 0')
end
it 'requires fair_distribution_window to be greater than 0' do
policy = build(:assignment_policy, fair_distribution_window: -1)
expect(policy).not_to be_valid
expect(policy.errors[:fair_distribution_window]).to include('must be greater than 0')
end
end
describe 'enum values' do
let(:assignment_policy) { create(:assignment_policy) }
describe 'conversation_priority' do
it 'can be set to earliest_created' do
assignment_policy.update!(conversation_priority: :earliest_created)
expect(assignment_policy.conversation_priority).to eq('earliest_created')
expect(assignment_policy.earliest_created?).to be true
end
it 'can be set to longest_waiting' do
assignment_policy.update!(conversation_priority: :longest_waiting)
expect(assignment_policy.conversation_priority).to eq('longest_waiting')
expect(assignment_policy.longest_waiting?).to be true
end
end
describe 'assignment_order' do
it 'can be set to round_robin' do
assignment_policy.update!(assignment_order: :round_robin)
expect(assignment_policy.assignment_order).to eq('round_robin')
expect(assignment_policy.round_robin?).to be true
end
end
end
end

View File

@@ -0,0 +1,200 @@
require 'rails_helper'
RSpec.describe Attachment do
let!(:message) { create(:message) }
describe 'external url validations' do
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
before do
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
context 'when it validates external url length' do
it 'valid when within limit' do
attachment.external_url = 'a' * Limits::URL_LENGTH_LIMIT
expect(attachment.valid?).to be true
end
it 'invalid when crossed the limit' do
attachment.external_url = 'a' * (Limits::URL_LENGTH_LIMIT + 5)
attachment.valid?
expect(attachment.errors[:external_url]).to include("is too long (maximum is #{Limits::URL_LENGTH_LIMIT} characters)")
end
end
end
describe 'download_url' do
it 'returns valid download url' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
expect(attachment.download_url).not_to be_nil
end
end
describe 'with_attached_file?' do
it 'returns true if its an attachment with file' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
expect(attachment.with_attached_file?).to be true
end
it 'returns false if its an attachment with out a file' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :fallback)
expect(attachment.with_attached_file?).to be false
end
end
describe 'push_event_data for instagram story mentions' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'returns external url as data and thumb urls when message is incoming' do
external_url = instagram_message.attachments.first.external_url
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
end
it 'returns original attachment url as data url if the message is outgoing' do
message = create(:message, :instagram_story_mention, message_type: :outgoing)
expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url
end
end
describe 'thumb_url' do
it 'returns empty string for non-image attachments' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
expect(attachment.thumb_url).to eq('')
end
it 'generates thumb_url for image attachments' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
expect(attachment.thumb_url).to be_present
end
it 'handles unrepresentable images gracefully' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
expect(attachment.thumb_url).to eq('')
end
end
describe 'meta data handling' do
let(:message) { create(:message) }
context 'when attachment is a contact type' do
let(:contact_attachment) do
message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890',
meta: {
first_name: 'John',
last_name: 'Doe'
}
)
end
it 'stores and retrieves meta data correctly' do
expect(contact_attachment.meta['first_name']).to eq('John')
expect(contact_attachment.meta['last_name']).to eq('Doe')
end
it 'includes meta data in push_event_data' do
event_data = contact_attachment.push_event_data
expect(event_data[:meta]).to eq({
'first_name' => 'John',
'last_name' => 'Doe'
})
end
it 'returns empty hash for meta if not set' do
attachment = message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890'
)
expect(attachment.push_event_data[:meta]).to eq({})
end
end
context 'when meta is used with other file types' do
let(:image_attachment) do
attachment = message.attachments.new(
account_id: message.account_id,
file_type: :image,
meta: { description: 'Test image' }
)
attachment.file.attach(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
attachment.save!
attachment
end
it 'preserves meta data with file attachments' do
expect(image_attachment.meta['description']).to eq('Test image')
end
end
end
describe 'push_event_data for embed attachments' do
it 'returns external url as data_url' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :embed, external_url: 'https://example.com/embed')
expect(attachment.push_event_data[:data_url]).to eq('https://example.com/embed')
end
end
describe 'file size validation' do
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
end
it 'respects configured limit' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('5')
attachment.errors.clear
attachment.send(:validate_file_size, 4.megabytes)
expect(attachment.errors[:file]).to be_empty
attachment.errors.clear
attachment.send(:validate_file_size, 6.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
it 'falls back to default when configured limit is invalid' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('-10')
attachment.errors.clear
attachment.send(:validate_file_size, 41.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
end
end

View File

@@ -0,0 +1,121 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe AutomationRule do
describe 'concerns' do
it_behaves_like 'reauthorizable'
end
describe 'associations' do
let(:account) { create(:account) }
let(:params) do
{
name: 'Notify Conversation Created and mark priority query',
description: 'Notify all administrator about conversation created and mark priority query',
event_name: 'conversation_created',
account_id: account.id,
conditions: [
{
attribute_key: 'browser_language',
filter_operator: 'equal_to',
values: ['en'],
query_operator: 'AND'
},
{
attribute_key: 'country_code',
filter_operator: 'equal_to',
values: %w[USA UK],
query_operator: nil
}
],
actions: [
{
action_name: :send_message,
action_params: ['Welcome to the chatwoot platform.']
},
{
action_name: :assign_team,
action_params: [1]
},
{
action_name: :add_label,
action_params: %w[support priority_customer]
},
{
action_name: :assign_agent,
action_params: [1]
}
]
}.with_indifferent_access
end
it 'returns valid record' do
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be true
end
it 'returns invalid record' do
params[:conditions][0].delete('query_operator')
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be false
expect(rule.errors.messages[:conditions]).to eq(['Automation conditions should have query operator.'])
end
it 'allows labels as a valid condition attribute' do
params[:conditions] = [
{
attribute_key: 'labels',
filter_operator: 'equal_to',
values: ['bug'],
query_operator: nil
}
]
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be true
end
it 'validates label condition operators' do
params[:conditions] = [
{
attribute_key: 'labels',
filter_operator: 'is_present',
values: [],
query_operator: nil
}
]
rule = FactoryBot.build(:automation_rule, params)
expect(rule.valid?).to be true
end
end
describe 'reauthorizable' do
context 'when prompt_reauthorization!' do
it 'marks the rule inactive' do
rule = create(:automation_rule)
expect(rule.active).to be true
rule.prompt_reauthorization!
expect(rule.active).to be false
end
end
context 'when reauthorization_required?' do
it 'unsets the error count if conditions are updated' do
rule = create(:automation_rule)
rule.prompt_reauthorization!
expect(rule.reauthorization_required?).to be true
rule.update!(conditions: [{ attribute_key: 'browser_language', filter_operator: 'equal_to', values: ['en'], query_operator: 'AND' }])
expect(rule.reauthorization_required?).to be false
end
it 'will not unset the error count if conditions are not updated' do
rule = create(:automation_rule)
rule.prompt_reauthorization!
expect(rule.reauthorization_required?).to be true
rule.update!(name: 'Updated name')
expect(rule.reauthorization_required?).to be true
end
end
end
end

View File

@@ -0,0 +1,163 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Campaign do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:inbox) }
end
describe '.before_create' do
let(:account) { create(:account) }
let(:website_channel) { create(:channel_widget, account: account) }
let(:website_inbox) { create(:inbox, channel: website_channel, account: account) }
let(:campaign) { build(:campaign, account: account, inbox: website_inbox, display_id: nil, trigger_rules: { url: 'https://test.com' }) }
before do
campaign.save!
campaign.reload
end
it 'runs before_create callbacks' do
expect(campaign.display_id).to eq(1)
end
end
context 'when Inbox other then Website or Twilio SMS' do
before do
stub_request(:post, /graph.facebook.com/)
end
let(:account) { create(:account) }
let!(:facebook_channel) { create(:channel_facebook_page, account: account) }
let!(:facebook_inbox) { create(:inbox, channel: facebook_channel, account: account) }
let(:campaign) { build(:campaign, inbox: facebook_inbox, account: account) }
it 'would not save the campaigns' do
expect(campaign.save).to be false
expect(campaign.errors.full_messages.first).to eq 'Inbox Unsupported Inbox type'
end
end
context 'when a campaign is completed' do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let!(:campaign) { create(:campaign, account: account, inbox: web_widget.inbox, campaign_status: :completed, trigger_rules: { url: 'https://test.com' }) }
it 'would prevent further updates' do
campaign.title = 'new name'
expect(campaign.save).to be false
expect(campaign.errors.full_messages.first).to eq 'Status The campaign is already completed'
end
it 'can be deleted' do
campaign.destroy!
expect(described_class.exists?(campaign.id)).to be false
end
it 'cant be triggered' do
expect(Twilio::OneoffSmsCampaignService).not_to receive(:new).with(campaign: campaign)
expect(campaign.trigger!).to be_nil
end
end
describe 'ensure_correct_campaign_attributes' do
context 'when Twilio SMS campaign' do
let(:account) { create(:account) }
let!(:twilio_sms) { create(:channel_twilio_sms, account: account) }
let!(:twilio_inbox) { create(:inbox, channel: twilio_sms, account: account) }
let(:campaign) { build(:campaign, account: account, inbox: twilio_inbox) }
it 'only saves campaign type as oneoff and wont leave scheduled_at empty' do
campaign.campaign_type = 'ongoing'
campaign.save!
expect(campaign.reload.campaign_type).to eq 'one_off'
expect(campaign.scheduled_at.present?).to be true
end
it 'calls twilio service on trigger!' do
sms_service = double
expect(Twilio::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform)
campaign.save!
campaign.trigger!
end
end
context 'when SMS campaign' do
let(:account) { create(:account) }
let!(:sms_channel) { create(:channel_sms, account: account) }
let!(:sms_inbox) { create(:inbox, channel: sms_channel, account: account) }
let(:campaign) { build(:campaign, account: account, inbox: sms_inbox) }
it 'only saves campaign type as oneoff and wont leave scheduled_at empty' do
campaign.campaign_type = 'ongoing'
campaign.save!
expect(campaign.reload.campaign_type).to eq 'one_off'
expect(campaign.scheduled_at.present?).to be true
end
it 'calls sms service on trigger!' do
sms_service = double
expect(Sms::OneoffSmsCampaignService).to receive(:new).with(campaign: campaign).and_return(sms_service)
expect(sms_service).to receive(:perform)
campaign.save!
campaign.trigger!
end
end
context 'when Website campaign' do
let(:campaign) { build(:campaign) }
it 'only saves campaign type as ongoing' do
campaign.campaign_type = 'one_off'
campaign.save!
expect(campaign.reload.campaign_type).to eq 'ongoing'
end
end
end
context 'when validating sender' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:inbox) { create(:inbox, channel: web_widget, account: account) }
it 'allows sender from the same account' do
campaign = build(:campaign, inbox: inbox, account: account, sender: user)
expect(campaign).to be_valid
end
it 'does not allow sender from different account' do
other_account = create(:account)
other_user = create(:user, account: other_account)
campaign = build(:campaign, inbox: inbox, account: account, sender: other_user)
expect(campaign).not_to be_valid
expect(campaign.errors[:sender_id]).to include(
'must belong to the same account as the campaign'
)
end
end
context 'when validating inbox' do
let(:account) { create(:account) }
let(:other_account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:inbox) { create(:inbox, channel: web_widget, account: account) }
let(:other_account_inbox) { create(:inbox, account: other_account) }
it 'allows inbox from the same account' do
campaign = build(:campaign, inbox: inbox, account: account)
expect(campaign).to be_valid
end
it 'does not allow inbox from different account' do
campaign = build(:campaign, inbox: other_account_inbox, account: account)
expect(campaign).not_to be_valid
expect(campaign.errors[:inbox_id]).to include(
'must belong to the same account as the campaign'
)
end
end
end

View File

@@ -0,0 +1,65 @@
require 'rails_helper'
RSpec.describe Category do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:portal) }
it { is_expected.to have_many(:articles) }
it { is_expected.to have_many(:sub_categories) }
it { is_expected.to have_many(:associated_categories) }
it { is_expected.to have_many(:related_categories) }
end
describe 'validations' do
let!(:account) { create(:account) }
let(:user) { create(:user, account_ids: [account.id], role: :agent) }
let!(:portal) { create(:portal, account_id: account.id, config: { allowed_locales: ['en'] }) }
it 'returns erros when locale is not allowed in the portal' do
category = create(:category, slug: 'category_1', locale: 'en', portal_id: portal.id)
expect(category).to be_valid
category.update(locale: 'es')
expect(category.errors.full_messages[0]).to eq("Locale es of category is not part of portal's [\"en\"].")
end
end
describe 'search' do
let!(:account) { create(:account) }
let(:user) { create(:user, account_ids: [account.id], role: :agent) }
let!(:portal_1) { create(:portal, account_id: account.id, config: { allowed_locales: %w[en es] }) }
let!(:portal_2) { create(:portal, account_id: account.id, config: { allowed_locales: %w[en es] }) }
before do
create(:category, slug: 'category_1', locale: 'en', portal_id: portal_1.id)
create(:category, slug: 'category_2', locale: 'es', portal_id: portal_1.id)
create(:category, slug: 'category_3', locale: 'es', portal_id: portal_2.id)
end
context 'when no parameters passed' do
it 'returns all the articles in portal' do
records = portal_1.categories.search({})
expect(records.count).to eq(portal_1.categories.count)
records = portal_2.categories.search({})
expect(records.count).to eq(portal_2.categories.count)
end
end
context 'when params passed' do
it 'returns all the categories with all the params filters' do
params = { locale: 'es' }
records = portal_2.categories.search(params)
expect(records.count).to eq(1)
params = { locale: 'en' }
records = portal_1.categories.search(params)
expect(records.count).to eq(1)
end
end
end
end

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::Api do
# This validation happens in ApplicationRecord
describe 'length validations' do
let(:channel_api) { create(:channel_api) }
context 'when it validates webhook_url length' do
it 'valid when within limit' do
channel_api.webhook_url = 'a' * Limits::URL_LENGTH_LIMIT
expect(channel_api.valid?).to be true
end
it 'invalid when crossed the limit' do
channel_api.webhook_url = 'a' * (Limits::URL_LENGTH_LIMIT + 1)
channel_api.valid?
expect(channel_api.errors[:webhook_url]).to include("is too long (maximum is #{Limits::URL_LENGTH_LIMIT} characters)")
end
end
end
end

View File

@@ -0,0 +1,49 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Email do
let(:channel) { create(:channel_email) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for email' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:email_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
it 'has a valid name' do
expect(channel.name).to eq('Email')
end
context 'when microsoft?' do
it 'returns false' do
expect(channel.microsoft?).to be(false)
end
it 'returns true' do
channel.provider = 'microsoft'
expect(channel.microsoft?).to be(true)
end
end
context 'when google?' do
it 'returns false' do
expect(channel.google?).to be(false)
end
it 'returns true' do
channel.provider = 'google'
expect(channel.google?).to be(true)
end
end
end

View File

@@ -0,0 +1,38 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::FacebookPage do
before do
stub_request(:post, /graph.facebook.com/)
end
let(:channel) { create(:channel_facebook_page) }
it { is_expected.to validate_presence_of(:account_id) }
# it { is_expected.to validate_uniqueness_of(:page_id).scoped_to(:account_id) }
it { is_expected.to belong_to(:account) }
it { is_expected.to have_one(:inbox).dependent(:destroy_async) }
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for facebook' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:facebook_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
it 'has a valid name' do
expect(channel.name).to eq('Facebook')
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Instagram do
let(:channel) { create(:channel_instagram) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:access_token) }
it { is_expected.to validate_presence_of(:instagram_id) }
it { is_expected.to belong_to(:account) }
it { is_expected.to have_one(:inbox).dependent(:destroy_async) }
it 'has a valid name' do
expect(channel.name).to eq('Instagram')
end
describe 'concerns' do
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for instagram' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:instagram_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
end

View File

@@ -0,0 +1,174 @@
require 'rails_helper'
RSpec.describe Channel::Telegram do
let(:telegram_channel) { create(:channel_telegram) }
describe '#convert_markdown_to_telegram_html' do
subject { telegram_channel.send(:convert_markdown_to_telegram_html, text) }
context 'when text contains multiple newline characters' do
let(:text) { "Line one\nLine two\n\nLine four" }
it 'preserves multiple newline characters' do
expect(subject).to eq("Line one\nLine two\n\nLine four")
end
end
context 'when text contains broken markdown' do
let(:text) { 'This is a **broken markdown with <b>HTML</b> tags.' }
it 'does not break and properly converts to Telegram HTML format and escapes html tags' do
expect(subject).to eq('This is a **broken markdown with &lt;b&gt;HTML&lt;/b&gt; tags.')
end
end
context 'when text contains markdown and HTML elements' do
let(:text) { "Hello *world*! This is <b>bold</b> and this is <i>italic</i>.\nThis is a new line." }
it 'converts markdown to Telegram HTML format and escapes other html' do
expect(subject).to eq("Hello <em>world</em>! This is &lt;b&gt;bold&lt;/b&gt; and this is &lt;i&gt;italic&lt;/i&gt;.\nThis is a new line.")
end
end
context 'when text contains unsupported HTML tags' do
let(:text) { 'This is a <span>test</span> with unsupported tags.' }
it 'removes unsupported HTML tags' do
expect(subject).to eq('This is a &lt;span&gt;test&lt;/span&gt; with unsupported tags.')
end
end
context 'when text contains special characters' do
let(:text) { 'Special characters: & < >' }
it 'escapes special characters' do
expect(subject).to eq('Special characters: &amp; &lt; &gt;')
end
end
context 'when text contains markdown links' do
let(:text) { 'Check this [link](http://example.com) out!' }
it 'converts markdown links to Telegram HTML format' do
expect(subject).to eq('Check this <a href="http://example.com">link</a> out!')
end
end
end
context 'when a valid message and empty attachments' do
it 'send message' do
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send message with markdown converted to telegram HTML' do
message = create(:message, message_type: :outgoing, content: '**test** *test* ~test~',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: "chat_id=123&text=#{
ERB::Util.url_encode('<strong>test</strong> <em>test</em> ~test~')
}&reply_markup=&parse_mode=HTML&reply_to_message_id="
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send message with reply_markup' do
message = create(
:message, message_type: :outgoing, content: 'test', content_type: 'input_select',
content_attributes: { 'items' => [{ 'title' => 'test', 'value' => 'test' }] },
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' })
)
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test' \
'&reply_markup=%7B%22one_time_keyboard%22%3Atrue%2C%22inline_keyboard%22%3A%5B%5B%7B%22text%22%3A%22test%22%2C%22' \
'callback_data%22%3A%22test%22%7D%5D%5D%7D&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'sends message with business_connection_id' do
additional_attributes = { 'chat_id' => '123', 'business_connection_id' => 'eooW3KF5WB5HxTD7T826' }
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: additional_attributes))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id=&business_connection_id=eooW3KF5WB5HxTD7T826'
)
.to_return(
status: 200,
body: { result: { message_id: 'telegram_123' } }.to_json,
headers: { 'Content-Type' => 'application/json' }
)
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_123')
end
it 'send text message failed' do
message = create(:message, message_type: :outgoing, content: 'test',
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
stub_request(:post, "https://api.telegram.org/bot#{telegram_channel.bot_token}/sendMessage")
.with(
body: 'chat_id=123&text=test&reply_markup=&parse_mode=HTML&reply_to_message_id='
)
.to_return(
status: 403,
headers: { 'Content-Type' => 'application/json' },
body: {
ok: false,
error_code: '403',
description: 'Forbidden: bot was blocked by the user'
}.to_json
)
telegram_channel.send_message_on_telegram(message)
expect(message.reload.status).to eq('failed')
expect(message.reload.external_error).to eq('403, Forbidden: bot was blocked by the user')
end
end
context 'when message contains attachments' do
let(:message) do
create(:message, message_type: :outgoing, content: nil,
conversation: create(:conversation, inbox: telegram_channel.inbox, additional_attributes: { 'chat_id' => '123' }))
end
it 'calls send attachment service' do
telegram_attachment_service = double
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
allow(Telegram::SendAttachmentsService).to receive(:new).with(message: message).and_return(telegram_attachment_service)
allow(telegram_attachment_service).to receive(:perform).and_return('telegram_456')
expect(telegram_channel.send_message_on_telegram(message)).to eq('telegram_456')
end
end
end

View File

@@ -0,0 +1,85 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::TwilioSms do
describe '#validations' do
context 'with phone number blank' do
let!(:sms_channel) { create(:channel_twilio_sms, medium: :sms, phone_number: nil) }
it 'allows channel to create with blank phone number' do
sms_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: '')
expect(sms_channel_1).to be_valid
expect(sms_channel_1.messaging_service_sid).to be_present
expect(sms_channel_1.phone_number).to be_blank
expect(sms_channel.phone_number).to be_nil
sms_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: nil)
expect(sms_channel_1.phone_number).to be_blank
expect(sms_channel_1.messaging_service_sid).to be_present
end
it 'throws error for whatsapp channel' do
whatsapp_channel_1 = create(:channel_twilio_sms, medium: :sms, phone_number: '', messaging_service_sid: 'MGec8130512b5dd462cfe03095ec1111ed')
expect do
create(:channel_twilio_sms, medium: :whatsapp, phone_number: 'whatsapp', messaging_service_sid: 'MGec8130512b5dd462cfe03095ec1111ed')
end.to raise_error(ActiveRecord::RecordInvalid) { |error| expect(error.message).to eq 'Validation failed: Phone number must be blank' }
expect(whatsapp_channel_1).to be_valid
end
end
end
describe '#send_message' do
let(:channel) { create(:channel_twilio_sms) }
let(:twilio_client) { instance_double(Twilio::REST::Client) }
let(:twilio_messages) { double }
before do
allow(Twilio::REST::Client).to receive(:new).and_return(twilio_client)
allow(twilio_client).to receive(:messages).and_return(twilio_messages)
end
it 'sends via twilio client' do
expect(twilio_messages).to receive(:create).with(
messaging_service_sid: channel.messaging_service_sid,
to: '+15555550111',
body: 'hello world',
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world')
end
context 'with a "from" phone number' do
let(:channel) { create(:channel_twilio_sms, :with_phone_number) }
it 'sends via twilio client' do
expect(twilio_messages).to receive(:create).with(
from: channel.phone_number,
to: '+15555550111',
body: 'hello world',
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world')
end
end
context 'with media urls' do
it 'supplies a media url' do
expect(twilio_messages).to receive(:create).with(
messaging_service_sid: channel.messaging_service_sid,
to: '+15555550111',
body: 'hello world',
media_url: ['https://example.com/1.jpg'],
status_callback: 'http://localhost:3000/twilio/delivery_status'
).once
channel.send_message(to: '+15555550111', body: 'hello world', media_url: ['https://example.com/1.jpg'])
end
end
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Channel::WebWidget do
context 'when
web widget channel' do
let!(:channel_widget) { create(:channel_widget) }
it 'pre chat options' do
expect(channel_widget.pre_chat_form_options['pre_chat_message']).to eq 'Share your queries or comments here.'
expect(channel_widget.pre_chat_form_options['pre_chat_fields'].length).to eq 3
end
end
end

View File

@@ -0,0 +1,212 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Channel::Whatsapp do
describe 'concerns' do
let(:channel) { create(:channel_whatsapp) }
before do
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
stub_request(:get, 'https://waba.360dialog.io/v1/configs/templates')
end
it_behaves_like 'reauthorizable'
context 'when prompt_reauthorization!' do
it 'calls channel notifier mail for whatsapp' do
admin_mailer = double
mailer_double = double
expect(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(admin_mailer)
expect(admin_mailer).to receive(:whatsapp_disconnect).with(channel.inbox).and_return(mailer_double)
expect(mailer_double).to receive(:deliver_later)
channel.prompt_reauthorization!
end
end
end
describe 'validate_provider_config' do
let(:channel) { build(:channel_whatsapp, provider: 'whatsapp_cloud', account: create(:account)) }
it 'validates false when provider config is wrong' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key').to_return(status: 401)
expect(channel.save).to be(false)
end
it 'validates true when provider config is right' do
stub_request(:get, 'https://graph.facebook.com/v14.0//message_templates?access_token=test_key')
.to_return(status: 200,
body: { data: [{
id: '123456789', name: 'test_template'
}] }.to_json)
expect(channel.save).to be(true)
end
end
describe 'webhook_verify_token' do
before do
# Stub webhook setup to prevent HTTP calls during channel creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
end
it 'generates webhook_verify_token if not present' do
channel = create(:channel_whatsapp,
provider_config: {
'webhook_verify_token' => nil,
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
provider: 'whatsapp_cloud',
account: create(:account),
validate_provider_config: false,
sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).not_to be_nil
end
it 'does not generate webhook_verify_token if present' do
channel = create(:channel_whatsapp,
provider: 'whatsapp_cloud',
provider_config: {
'webhook_verify_token' => '123',
'api_key' => 'test_key',
'business_account_id' => '123456789'
},
account: create(:account),
validate_provider_config: false,
sync_templates: false)
expect(channel.provider_config['webhook_verify_token']).to eq '123'
end
end
describe 'webhook setup after creation' do
let(:account) { create(:account) }
let(:webhook_service) { instance_double(Whatsapp::WebhookSetupService) }
before do
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
allow(webhook_service).to receive(:perform)
end
context 'when channel is created through embedded signup' do
it 'does not raise error if webhook setup fails' do
allow(webhook_service).to receive(:perform).and_raise(StandardError, 'Webhook error')
expect do
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
sync_templates: false)
end.not_to raise_error
end
end
context 'when channel is created through manual setup' do
it 'setups webhooks via after_commit callback' do
expect(Whatsapp::WebhookSetupService).to receive(:new).and_return(webhook_service)
expect(webhook_service).to receive(:perform)
# Explicitly set source to nil to test manual setup behavior (not embedded_signup)
create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'source' => nil
},
validate_provider_config: false,
sync_templates: false)
end
end
context 'when channel is created with different provider' do
it 'does not setup webhooks for 360dialog provider' do
expect(Whatsapp::WebhookSetupService).not_to receive(:new)
create(:channel_whatsapp,
account: account,
provider: 'default',
provider_config: {
'source' => 'embedded_signup',
'api_key' => 'test_360dialog_key'
},
validate_provider_config: false,
sync_templates: false)
end
end
end
describe '#teardown_webhooks' do
let(:account) { create(:account) }
context 'when channel is whatsapp_cloud with embedded_signup' do
it 'calls WebhookTeardownService on destroy' do
# Mock the setup service to prevent HTTP calls during creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'source' => 'embedded_signup',
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token',
'phone_number_id' => '123456789'
},
validate_provider_config: false,
sync_templates: false)
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
allow(teardown_service).to receive(:perform)
channel.destroy
expect(Whatsapp::WebhookTeardownService).to have_received(:new).with(channel)
expect(teardown_service).to have_received(:perform)
end
end
context 'when channel is not embedded_signup' do
it 'calls WebhookTeardownService on destroy' do
# Mock the setup service to prevent HTTP calls during creation
setup_service = instance_double(Whatsapp::WebhookSetupService)
allow(Whatsapp::WebhookSetupService).to receive(:new).and_return(setup_service)
allow(setup_service).to receive(:perform)
channel = create(:channel_whatsapp,
account: account,
provider: 'whatsapp_cloud',
provider_config: {
'business_account_id' => 'test_waba_id',
'api_key' => 'test_access_token'
},
validate_provider_config: false,
sync_templates: false)
teardown_service = instance_double(Whatsapp::WebhookTeardownService)
allow(Whatsapp::WebhookTeardownService).to receive(:new).with(channel).and_return(teardown_service)
allow(teardown_service).to receive(:perform)
channel.destroy
expect(teardown_service).to have_received(:perform)
end
end
end
end

View File

@@ -0,0 +1,9 @@
require 'rails_helper'
shared_examples_for 'access_tokenable' do
let(:obj) { create(described_class.to_s.underscore) }
it 'creates access token on create' do
expect(obj.access_token).not_to be_nil
end
end

View File

@@ -0,0 +1,63 @@
require 'rails_helper'
RSpec.describe AccountEmailRateLimitable do
let(:account) { create(:account) }
describe '#email_rate_limit' do
it 'returns account-level override when set' do
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
it 'returns global config when no account override' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
expect(account.email_rate_limit).to eq(200)
end
it 'returns account override over global config' do
InstallationConfig.where(name: 'ACCOUNT_EMAILS_LIMIT').first_or_create(value: 200)
account.update!(limits: { 'emails' => 50 })
expect(account.email_rate_limit).to eq(50)
end
end
describe '#within_email_rate_limit?' do
before do
account.update!(limits: { 'emails' => 2 })
end
it 'returns true when under limit' do
expect(account).to be_within_email_rate_limit
end
it 'returns false when at limit' do
2.times { account.increment_email_sent_count }
expect(account).not_to be_within_email_rate_limit
end
end
describe '#increment_email_sent_count' do
it 'increments the counter' do
expect { account.increment_email_sent_count }.to change(account, :emails_sent_today).by(1)
end
it 'sets TTL on first increment' do
key = format(Redis::Alfred::ACCOUNT_OUTBOUND_EMAIL_COUNT_KEY, account_id: account.id, date: Time.zone.today.to_s)
allow(Redis::Alfred).to receive(:incr).and_return(1)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).to have_received(:expire).with(key, AccountEmailRateLimitable::OUTBOUND_EMAIL_TTL)
end
it 'does not reset TTL on subsequent increments' do
allow(Redis::Alfred).to receive(:incr).and_return(2)
allow(Redis::Alfred).to receive(:expire)
account.increment_email_sent_count
expect(Redis::Alfred).not_to have_received(:expire)
end
end
end

View File

@@ -0,0 +1,66 @@
# frozen_string_literal: true
require 'rails_helper'
shared_examples_for 'assignment_handler' do
describe '#update_team' do
let(:conversation) { create(:conversation, assignee: create(:user)) }
let(:agent) do
create(:user, email: 'agent@example.com', account: conversation.account, role: :agent, auto_offline: false)
end
let(:team) do
create(:team, account: conversation.account, allow_auto_assign: false)
end
context 'when agent is current user' do
before do
Current.user = agent
create(:team_member, team: team, user: agent)
create(:inbox_member, user: agent, inbox: conversation.inbox)
conversation.inbox.reload
end
it 'creates team assigned and unassigned message activity' do
expect(conversation.update(team: team)).to be true
expect(conversation.update(team: nil)).to be true
expect(Conversations::ActivityMessageJob).to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "Assigned to #{team.name} by #{agent.name}" }))
expect(Conversations::ActivityMessageJob).to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "Unassigned from #{team.name} by #{agent.name}" }))
end
it 'changes assignee to nil if they doesnt belong to the team and allow_auto_assign is false' do
expect(team.allow_auto_assign).to be false
conversation.update(team: team)
expect(conversation.reload.assignee).to be_nil
end
it 'changes assignee to a team member if allow_auto_assign is enabled' do
team.update!(allow_auto_assign: true)
conversation.update(team: team)
expect(conversation.reload.assignee).to eq agent
expect(Conversations::ActivityMessageJob).to(have_been_enqueued.at_least(:once)
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
content: "Assigned to #{conversation.assignee.name} via #{team.name} by #{agent.name}" }))
end
it 'wont change assignee if he is already a team member' do
team.update!(allow_auto_assign: true)
assignee = create(:user, account: conversation.account, role: :agent)
create(:inbox_member, user: assignee, inbox: conversation.inbox)
create(:team_member, team: team, user: assignee)
conversation.update(assignee: assignee)
conversation.update(team: team)
expect(conversation.reload.assignee).to eq assignee
end
end
end
end

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
require 'rails_helper'
shared_examples_for 'auto_assignment_handler' do
describe '#auto assignment' do
let(:account) { create(:account) }
let(:agent) { create(:user, email: 'agent1@example.com', account: account, auto_offline: false) }
let(:inbox) { create(:inbox, account: account) }
let(:conversation) do
create(
:conversation,
account: account,
contact: create(:contact, account: account),
inbox: inbox,
assignee: nil
)
end
before do
create(:inbox_member, inbox: inbox, user: agent)
allow(Redis::Alfred).to receive(:rpoplpush).and_return(agent.id)
end
it 'runs round robin on after_save callbacks' do
expect(conversation.reload.assignee).to eq(agent)
end
it 'will not auto assign agent if enable_auto_assignment is false' do
inbox.update(enable_auto_assignment: false)
expect(conversation.reload.assignee).to be_nil
end
it 'will not auto assign agent if its a bot conversation' do
conversation = create(
:conversation,
account: account,
contact: create(:contact, account: account),
inbox: inbox,
status: 'pending',
assignee: nil
)
expect(conversation.reload.assignee).to be_nil
end
it 'gets triggered on update only when status changes to open' do
conversation.status = 'resolved'
conversation.save!
expect(conversation.reload.assignee).to eq(agent)
inbox.inbox_members.where(user_id: agent.id).first.destroy!
# round robin changes assignee in this case since agent doesn't have access to inbox
agent2 = create(:user, email: 'agent2@example.com', account: account, auto_offline: false)
create(:inbox_member, inbox: inbox, user: agent2)
allow(Redis::Alfred).to receive(:rpoplpush).and_return(agent2.id)
conversation.status = 'open'
conversation.save!
expect(conversation.reload.assignee).to eq(agent2)
end
end
end

View File

@@ -0,0 +1,44 @@
require 'rails_helper'
shared_examples_for 'avatarable' do
let(:avatarable) { create(described_class.to_s.underscore) }
it 'has avatar attachment defined' do
expect(avatarable).to respond_to(:avatar)
expect(avatarable.avatar).to respond_to(:attach)
end
it 'add avatar_url method' do
expect(avatarable.respond_to?(:avatar_url)).to be true
end
context 'when avatarable has an email attribute' do
it 'enques job when email is changed on avatarable create' do
avatarable = build(described_class.to_s.underscore, account: create(:account))
if avatarable.respond_to?(:email)
avatarable.email = 'test@test.com'
avatarable.skip_reconfirmation! if avatarable.is_a? User
expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original
end
avatarable.save!
expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email)
end
it 'enques job when email is changes on avatarable update' do
if avatarable.respond_to?(:email)
avatarable.email = 'xyc@test.com'
avatarable.skip_reconfirmation! if avatarable.is_a? User
expect(Avatar::AvatarFromGravatarJob).to receive(:set).with(wait: 30.seconds).and_call_original
end
avatarable.save!
expect(Avatar::AvatarFromGravatarJob).to have_been_enqueued.with(avatarable, avatarable.email) if avatarable.respond_to?(:email)
end
it 'will not enqueu when email is not changed on avatarable update' do
avatarable.updated_at = Time.now.utc
expect do
avatarable.save!
end.not_to have_enqueued_job(Avatar::AvatarFromGravatarJob)
end
end
end

View File

@@ -0,0 +1,69 @@
require 'rails_helper'
RSpec.describe CacheKeys do
let(:test_model) do
Struct.new(:id) do
include CacheKeys
def fetch_value_for_key(_id, _key)
'value'
end
end.new(1)
end
before do
allow(Redis::Alfred).to receive(:delete)
allow(Redis::Alfred).to receive(:set)
allow(Redis::Alfred).to receive(:setex)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
describe '#cache_keys' do
it 'returns a hash of cache keys' do
expected_keys = test_model.class.cacheable_models.map do |model|
[model.name.underscore.to_sym, 'value']
end.to_h
expect(test_model.cache_keys).to eq(expected_keys)
end
end
describe '#update_cache_key' do
it 'updates the cache key' do
allow(Time).to receive(:now).and_return(Time.parse('2023-05-29 00:00:00 UTC'))
test_model.update_cache_key('label')
expect(Redis::Alfred).to have_received(:setex).with('idb-cache-key-account-1-label', kind_of(Integer), CacheKeys::CACHE_KEYS_EXPIRY)
end
it 'dispatches a cache update event' do
test_model.update_cache_key('label')
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
kind_of(ActiveSupport::TimeWithZone),
cache_keys: test_model.cache_keys,
account: test_model
)
end
end
describe '#reset_cache_keys' do
it 'invalidates all cache keys for cacheable models' do
test_model.reset_cache_keys
test_model.class.cacheable_models.each do |model|
expect(Redis::Alfred).to have_received(:setex).with("idb-cache-key-account-1-#{model.name.underscore}", kind_of(Integer),
CacheKeys::CACHE_KEYS_EXPIRY)
end
end
it 'dispatches a cache update event' do
test_model.reset_cache_keys
expect(Rails.configuration.dispatcher).to have_received(:dispatch).with(
CacheKeys::ACCOUNT_CACHE_INVALIDATED,
kind_of(ActiveSupport::TimeWithZone),
cache_keys: test_model.cache_keys,
account: test_model
)
end
end
end

View File

@@ -0,0 +1,134 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe CaptainFeaturable do
let(:account) { create(:account) }
describe 'dynamic method generation' do
it 'generates enabled? methods for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account).to respond_to("captain_#{feature_key}_enabled?")
end
end
it 'generates model accessor methods for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account).to respond_to("captain_#{feature_key}_model")
end
end
end
describe 'feature enabled methods' do
context 'when no features are explicitly enabled' do
it 'returns false for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
end
end
end
context 'when features are explicitly enabled' do
before do
account.update!(captain_features: { 'editor' => true, 'assistant' => true })
end
it 'returns true for enabled features' do
expect(account.captain_editor_enabled?).to be true
expect(account.captain_assistant_enabled?).to be true
end
it 'returns false for disabled features' do
expect(account.captain_copilot_enabled?).to be false
expect(account.captain_label_suggestion_enabled?).to be false
end
end
context 'when captain_features is nil' do
before do
account.update!(captain_features: nil)
end
it 'returns false for all features' do
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to be false
end
end
end
end
describe 'model accessor methods' do
context 'when no models are explicitly configured' do
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
context 'when models are explicitly configured' do
before do
account.update!(captain_models: {
'editor' => 'gpt-4.1-mini',
'assistant' => 'gpt-5.1',
'label_suggestion' => 'gpt-4.1-nano'
})
end
it 'returns configured models for configured features' do
expect(account.captain_editor_model).to eq('gpt-4.1-mini')
expect(account.captain_assistant_model).to eq('gpt-5.1')
expect(account.captain_label_suggestion_model).to eq('gpt-4.1-nano')
end
it 'returns default models for unconfigured features' do
expect(account.captain_copilot_model).to eq(Llm::Models.default_model_for('copilot'))
expect(account.captain_audio_transcription_model).to eq(Llm::Models.default_model_for('audio_transcription'))
end
end
context 'when configured with invalid model' do
before do
account.captain_models = { 'editor' => 'invalid-model' }
end
it 'falls back to default model' do
expect(account.captain_editor_model).to eq(Llm::Models.default_model_for('editor'))
end
end
context 'when captain_models is nil' do
before do
account.update!(captain_models: nil)
end
it 'returns default models for all features' do
Llm::Models.feature_keys.each do |feature_key|
expected_default = Llm::Models.default_model_for(feature_key)
expect(account.send("captain_#{feature_key}_model")).to eq(expected_default)
end
end
end
end
describe 'integration with existing captain_preferences' do
it 'enabled? methods use the same logic as captain_preferences[:features]' do
account.update!(captain_features: { 'editor' => true, 'copilot' => true })
prefs = account.captain_preferences
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_enabled?")).to eq(prefs[:features][feature_key])
end
end
it 'model methods use the same logic as captain_preferences[:models]' do
account.update!(captain_models: { 'editor' => 'gpt-4.1-mini', 'assistant' => 'gpt-5.2' })
prefs = account.captain_preferences
Llm::Models.feature_keys.each do |feature_key|
expect(account.send("captain_#{feature_key}_model")).to eq(prefs[:models][feature_key])
end
end
end
end

View File

@@ -0,0 +1,153 @@
require 'rails_helper'
RSpec.describe JsonSchemaValidator, type: :validator do
schema = {
'type' => 'object',
'properties' => {
'name' => { 'type' => 'string' },
'age' => { 'type' => 'integer', 'minimum' => 18, 'maximum' => 100 },
'is_active' => { 'type' => 'boolean' },
'tags' => { 'type' => 'array' },
'score' => { 'type' => 'number', 'minimum' => 0, 'maximum' => 10 },
'address' => {
'type' => 'object',
'properties' => {
'street' => { 'type' => 'string' },
'city' => { 'type' => 'string' }
},
'required' => %w[street city]
}
},
:required => %w[name age]
}.to_json.freeze
# Create a simple test model for validation
before_all do
# rubocop:disable Lint/ConstantDefinitionInBlock
# rubocop:disable RSpec/LeakyConstantDeclaration
TestModelForJSONValidation = Struct.new(:additional_attributes) do
include ActiveModel::Validations
validates_with JsonSchemaValidator, schema: schema
end
# rubocop:enable Lint/ConstantDefinitionInBlock
# rubocop:enable RSpec/LeakyConstantDeclaration
end
context 'with valid data' do
let(:valid_data) do
{
'name' => 'John Doe',
'age' => 30,
'tags' => %w[tag1 tag2],
'is_active' => true,
'address' => {
'street' => '123 Main St',
'city' => 'Iceland'
}
}
end
it 'passes validation' do
model = TestModelForJSONValidation.new(valid_data)
expect(model.valid?).to be true
expect(model.errors.full_messages).to be_empty
end
end
context 'with missing required attributes' do
let(:invalid_data) do
{
'name' => 'John Doe',
'address' => {
'street' => '123 Main St',
'city' => 'Iceland'
}
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({ :age => ['is required'] })
end
end
context 'with incorrect address hash' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => 30,
'address' => 'not-a-hash'
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({ :address => ['must be of type hash'] })
end
end
context 'with incorrect types' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => '30',
'is_active' => 'some-value',
'tags' => 'not-an-array',
'address' => {
'street' => 123,
'city' => 'Iceland'
}
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({ :age => ['must be of type integer'], :'address/street' => ['must be of type string'],
:is_active => ['must be of type boolean'], :tags => ['must be of type array'] })
end
end
context 'with value below minimum' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => 15,
'score' => -1,
'is_active' => true
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({
:age => ['must be greater than or equal to 18'],
:score => ['must be greater than or equal to 0']
})
end
end
context 'with value above maximum' do
let(:invalid_data) do
{
'name' => 'John Doe',
'age' => 120,
'score' => 11,
'is_active' => true
}
end
it 'fails validation' do
model = TestModelForJSONValidation.new(invalid_data)
expect(model.valid?).to be false
expect(model.errors.messages).to eq({
:age => ['must be less than or equal to 100'],
:score => ['must be less than or equal to 10']
})
end
end
end

View File

@@ -0,0 +1,227 @@
require 'rails_helper'
shared_examples_for 'liqudable' do
context 'when liquid is present in content' do
let(:contact) { create(:contact, name: 'john', phone_number: '+912883', custom_attributes: { customer_type: 'platinum' }) }
let(:conversation) { create(:conversation, id: 1, contact: contact, custom_attributes: { priority: 'high' }) }
context 'when message is incoming' do
let(:message) { build(:message, conversation: conversation, message_type: 'incoming') }
it 'will not process liquid in content' do
message.content = 'hey {{contact.name}} how are you?'
message.save!
expect(message.content).to eq 'hey {{contact.name}} how are you?'
end
end
context 'when message is outgoing' do
let(:message) { build(:message, conversation: conversation, message_type: 'outgoing') }
it 'set replaces liquid variables in message' do
message.content = 'hey {{contact.name}} how are you?'
message.save!
expect(message.content).to eq 'hey John how are you?'
end
it 'set replaces liquid custom attributes in message' do
message.content = 'Are you a {{contact.custom_attribute.customer_type}} customer,
If yes then the priority is {{conversation.custom_attribute.priority}}'
message.save!
expect(message.content).to eq 'Are you a platinum customer,
If yes then the priority is high'
end
it 'process liquid operators like default value' do
message.content = 'Can we send you an email at {{ contact.email | default: "default" }} ?'
message.save!
expect(message.content).to eq 'Can we send you an email at default ?'
end
it 'return empty string when value is not available' do
message.content = 'Can we send you an email at {{contact.email}}?'
message.save!
expect(message.content).to eq 'Can we send you an email at ?'
end
it 'will skip processing broken liquid tags' do
message.content = 'Can we send you an email at {{contact.email} {{hi}} ?'
message.save!
expect(message.content).to eq 'Can we send you an email at {{contact.email} {{hi}} ?'
end
it 'will not process liquid tags in multiple code blocks' do
message.content = 'hey {{contact.name}} how are you? ```code: {{contact.name}}``` ``` code: {{contact.name}} ``` test `{{contact.name}}`'
message.save!
expect(message.content).to eq 'hey John how are you? ```code: {{contact.name}}``` ``` code: {{contact.name}} ``` test `{{contact.name}}`'
end
it 'will not process liquid tags in single ticks' do
message.content = 'hey {{contact.name}} how are you? ` code: {{contact.name}} ` ` code: {{contact.name}} ` test'
message.save!
expect(message.content).to eq 'hey John how are you? ` code: {{contact.name}} ` ` code: {{contact.name}} ` test'
end
it 'will not throw error for broken quotes' do
message.content = 'hey {{contact.name}} how are you? ` code: {{contact.name}} ` ` code: {{contact.name}} test'
message.save!
expect(message.content).to eq 'hey John how are you? ` code: {{contact.name}} ` ` code: John test'
end
end
end
context 'when liquid is present in template_params' do
let(:contact) do
create(:contact, name: 'john', email: 'john@example.com', phone_number: '+912883', custom_attributes: { customer_type: 'platinum' })
end
let(:conversation) { create(:conversation, id: 1, contact: contact, custom_attributes: { priority: 'high' }) }
context 'when message is outgoing with template_params' do
let(:message) { build(:message, conversation: conversation, message_type: 'outgoing') }
it 'replaces liquid variables in template_params body' do
message.additional_attributes = {
'template_params' => {
'name' => 'greet',
'category' => 'MARKETING',
'language' => 'en',
'processed_params' => {
'body' => {
'customer_name' => '{{contact.name}}',
'customer_email' => '{{contact.email}}'
}
}
}
}
message.save!
body_params = message.additional_attributes['template_params']['processed_params']['body']
expect(body_params['customer_name']).to eq 'John'
expect(body_params['customer_email']).to eq 'john@example.com'
end
it 'replaces liquid variables in nested template_params' do
message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'header' => {
'media_url' => 'https://example.com/{{contact.name}}.jpg'
},
'body' => {
'customer_name' => '{{contact.name}}',
'priority' => '{{conversation.custom_attribute.priority}}'
},
'footer' => {
'company' => '{{account.name}}'
}
}
}
}
message.save!
processed = message.additional_attributes['template_params']['processed_params']
expect(processed['header']['media_url']).to eq 'https://example.com/John.jpg'
expect(processed['body']['customer_name']).to eq 'John'
expect(processed['body']['priority']).to eq 'high'
expect(processed['footer']['company']).to eq conversation.account.name
end
it 'handles arrays in template_params' do
message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'buttons' => [
{ 'type' => 'url', 'parameter' => 'https://example.com/{{contact.name}}' },
{ 'type' => 'text', 'parameter' => 'Hello {{contact.name}}' }
]
}
}
}
message.save!
buttons = message.additional_attributes['template_params']['processed_params']['buttons']
expect(buttons[0]['parameter']).to eq 'https://example.com/John'
expect(buttons[1]['parameter']).to eq 'Hello John'
end
it 'handles custom attributes in template_params' do
message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'body' => {
'customer_type' => '{{contact.custom_attribute.customer_type}}',
'priority' => '{{conversation.custom_attribute.priority}}'
}
}
}
}
message.save!
body_params = message.additional_attributes['template_params']['processed_params']['body']
expect(body_params['customer_type']).to eq 'platinum'
expect(body_params['priority']).to eq 'high'
end
it 'handles missing email with default filter in template_params' do
contact.update!(email: nil)
message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'body' => {
'customer_email' => '{{ contact.email | default: "no-email@example.com" }}'
}
}
}
}
message.save!
body_params = message.additional_attributes['template_params']['processed_params']['body']
expect(body_params['customer_email']).to eq 'no-email@example.com'
end
it 'handles broken liquid syntax in template_params gracefully' do
message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'body' => {
'broken_liquid' => '{{contact.name} {{invalid}}'
}
}
}
}
message.save!
body_params = message.additional_attributes['template_params']['processed_params']['body']
expect(body_params['broken_liquid']).to eq '{{contact.name} {{invalid}}'
end
it 'does not process template_params when message is incoming' do
incoming_message = build(:message, conversation: conversation, message_type: 'incoming')
incoming_message.additional_attributes = {
'template_params' => {
'name' => 'test_template',
'processed_params' => {
'body' => {
'customer_name' => '{{contact.name}}'
}
}
}
}
incoming_message.save!
body_params = incoming_message.additional_attributes['template_params']['processed_params']['body']
expect(body_params['customer_name']).to eq '{{contact.name}}'
end
it 'does not process template_params when not present' do
message.additional_attributes = { 'other_data' => 'test' }
expect { message.save! }.not_to raise_error
end
end
end
end

View File

@@ -0,0 +1,25 @@
require 'rails_helper'
shared_examples_for 'out_of_offisable' do
let(:obj) { create(described_class.to_s.underscore, working_hours_enabled: true, out_of_office_message: 'Message') }
it 'has after create callback' do
expect(obj.working_hours.count).to eq(7)
end
it 'is working on monday 10am' do
travel_to '26.10.2020 10:00'.to_datetime
expect(obj.working_now?).to be true
end
it 'is out of office on sunday 1pm' do
travel_to '01.11.2020 13:00'.to_datetime
expect(obj.out_of_office?).to be true
end
it 'updates the office hours via a hash' do
obj.update_working_hours([{ 'day_of_week' => 1, 'open_hour' => 10, 'open_minutes' => 0,
'close_hour' => 17, 'close_minutes' => 0 }])
expect(obj.reload.weekly_schedule.find { |schedule| schedule['day_of_week'] == 1 }['open_hour']).to eq 10
end
end

View File

@@ -0,0 +1,103 @@
require 'rails_helper'
shared_examples_for 'reauthorizable' do
let(:model) { described_class } # the class that includes the concern
let(:obj) { FactoryBot.create(model.to_s.underscore.tr('/', '_').to_sym) }
it 'authorization_error!' do
expect(obj.authorization_error_count).to eq 0
obj.authorization_error!
expect(obj.authorization_error_count).to eq 1
end
it 'prompts reauthorization when error threshold is passed' do
expect(obj.reauthorization_required?).to be false
obj.class::AUTHORIZATION_ERROR_THRESHOLD.times do
obj.authorization_error!
end
expect(obj.reauthorization_required?).to be true
end
# Helper methods to set up mailer mocks
def setup_automation_rule_mailer(_obj)
account_mailer = instance_double(AdministratorNotifications::AccountNotificationMailer)
automation_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
allow(AdministratorNotifications::AccountNotificationMailer).to receive(:with).and_return(account_mailer)
allow(account_mailer).to receive(:automation_rule_disabled).and_return(automation_mailer_response)
end
def setup_integrations_hook_mailer(obj)
integrations_mailer = instance_double(AdministratorNotifications::IntegrationsNotificationMailer)
slack_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
dialogflow_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
allow(AdministratorNotifications::IntegrationsNotificationMailer).to receive(:with).and_return(integrations_mailer)
allow(integrations_mailer).to receive(:slack_disconnect).and_return(slack_mailer_response)
allow(integrations_mailer).to receive(:dialogflow_disconnect).and_return(dialogflow_mailer_response)
# Allow the model to respond to slack? and dialogflow? methods
allow(obj).to receive(:slack?).and_return(true)
allow(obj).to receive(:dialogflow?).and_return(false)
end
def setup_channel_mailer(_obj)
channel_mailer = instance_double(AdministratorNotifications::ChannelNotificationsMailer)
facebook_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
whatsapp_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
email_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
instagram_mailer_response = instance_double(ActionMailer::MessageDelivery, deliver_later: true)
allow(AdministratorNotifications::ChannelNotificationsMailer).to receive(:with).and_return(channel_mailer)
allow(channel_mailer).to receive(:facebook_disconnect).and_return(facebook_mailer_response)
allow(channel_mailer).to receive(:whatsapp_disconnect).and_return(whatsapp_mailer_response)
allow(channel_mailer).to receive(:email_disconnect).and_return(email_mailer_response)
allow(channel_mailer).to receive(:instagram_disconnect).and_return(instagram_mailer_response)
end
describe 'prompt_reauthorization!' do
before do
# Setup mailer mocks based on model type
if model.to_s == 'AutomationRule'
setup_automation_rule_mailer(obj)
elsif model.to_s == 'Integrations::Hook'
setup_integrations_hook_mailer(obj)
else
setup_channel_mailer(obj)
end
end
it 'sets reauthorization required flag' do
expect(obj.reauthorization_required?).to be false
obj.prompt_reauthorization!
expect(obj.reauthorization_required?).to be true
end
it 'calls the correct mailer based on model type' do
obj.prompt_reauthorization!
if model.to_s == 'AutomationRule'
expect(AdministratorNotifications::AccountNotificationMailer).to have_received(:with).with(account: obj.account)
elsif model.to_s == 'Integrations::Hook'
expect(AdministratorNotifications::IntegrationsNotificationMailer).to have_received(:with).with(account: obj.account)
else
expect(AdministratorNotifications::ChannelNotificationsMailer).to have_received(:with).with(account: obj.account)
end
end
end
it 'reauthorized!' do
# setting up the object with the errors to validate its cleared on action
obj.authorization_error!
obj.prompt_reauthorization!
expect(obj.reauthorization_required?).to be true
expect(obj.authorization_error_count).not_to eq 0
obj.reauthorized!
# authorization errors are reset
expect(obj.authorization_error_count).to eq 0
expect(obj.reauthorization_required?).to be false
end
end

View File

@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe 'SwitchLocale Concern', type: :controller do
controller(ApplicationController) do
include SwitchLocale
def index
switch_locale { render plain: I18n.locale }
end
def account_locale
switch_locale_using_account_locale { render plain: I18n.locale }
end
end
let(:account) { create(:account, locale: 'es') }
let(:portal) { create(:portal, custom_domain: 'custom.example.com', config: { default_locale: 'fr_FR' }) }
describe '#switch_locale' do
context 'when locale is provided in params' do
it 'sets locale from params' do
get :index, params: { locale: 'es' }
expect(response.body).to eq('es')
end
it 'falls back to default locale if invalid' do
get :index, params: { locale: 'invalid' }
expect(response.body).to eq('en')
end
end
context 'when user has a locale set in ui_settings' do
let(:user) { create(:user, ui_settings: { 'locale' => 'es' }) }
before { controller.instance_variable_set(:@user, user) }
it 'returns the user locale' do
expect(controller.send(:locale_from_user)).to eq('es')
end
end
context 'when user does not have a locale set' do
let(:user) { create(:user, ui_settings: {}) }
before { controller.instance_variable_set(:@user, user) }
it 'returns nil' do
expect(controller.send(:locale_from_user)).to be_nil
end
end
context 'when request is from custom domain' do
before { request.host = portal.custom_domain }
it 'sets locale from portal' do
get :index
expect(response.body).to eq('fr')
end
it 'overrides portal locale with param' do
get :index, params: { locale: 'es' }
expect(response.body).to eq('es')
end
end
context 'when locale is not provided anywhere' do
it 'sets locale from environment variable' do
with_modified_env(DEFAULT_LOCALE: 'de_DE') do
get :index
expect(response.body).to eq('de')
end
end
it 'falls back to default locale if env locale invalid' do
with_modified_env(DEFAULT_LOCALE: 'invalid') do
get :index
expect(response.body).to eq('en')
end
end
end
end
describe '#switch_locale_using_account_locale' do
it 'sets locale from account' do
controller.instance_variable_set(:@current_account, account)
result = nil
controller.send(:switch_locale_using_account_locale) do
result = I18n.locale.to_s
end
expect(result).to eq('es')
end
end
end

View File

@@ -0,0 +1,107 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe ContactInbox do
describe 'pubsub_token' do
let(:contact_inbox) { create(:contact_inbox) }
it 'gets created on object create' do
obj = contact_inbox
expect(obj.pubsub_token).not_to be_nil
end
it 'does not get updated on object update' do
obj = contact_inbox
old_token = obj.pubsub_token
obj.update(source_id: '234234323')
expect(obj.pubsub_token).to eq(old_token)
end
it 'backfills pubsub_token on call for older objects' do
obj = create(:contact_inbox)
# to replicate an object with out pubsub_token
# rubocop:disable Rails/SkipsModelValidations
obj.update_column(:pubsub_token, nil)
# rubocop:enable Rails/SkipsModelValidations
obj.reload
# ensure the column is nil in database
results = ActiveRecord::Base.connection.execute('Select * from contact_inboxes;')
expect(results.first['pubsub_token']).to be_nil
new_token = obj.pubsub_token
obj.update(source_id: '234234323')
# the generated token shoul be persisted in db
expect(obj.pubsub_token).to eq(new_token)
end
end
describe 'validations' do
context 'when source_id' do
it 'allows source_id longer than 255 characters for channels without format restrictions' do
long_source_id = 'a' * 300
email_inbox = create(:inbox, channel: create(:channel_email))
contact = create(:contact, account: email_inbox.account)
contact_inbox = build(:contact_inbox, contact: contact, inbox: email_inbox, source_id: long_source_id)
expect(contact_inbox.valid?).to be(true)
expect { contact_inbox.save! }.not_to raise_error
expect(contact_inbox.reload.source_id).to eq(long_source_id)
expect(contact_inbox.source_id.length).to eq(300)
end
it 'validates whatsapp channel source_id' do
whatsapp_inbox = create(:channel_whatsapp, sync_templates: false, validate_provider_config: false).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '1234567890aaa')
ci_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: whatsapp_inbox, source_id: '+1234567890')
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
)
expect(ci_plus_in_source_id.valid?).to be(false)
expect(ci_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for whatsapp inbox. valid Regex (?-mix:^\\d{1,15}\\z)']
)
end
it 'validates twilio sms channel source_id' do
twilio_sms_inbox = create(:channel_twilio_sms).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_sms_inbox, source_id: '+1234567890')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_sms_inbox, source_id: '+1234567890aaa')
ci_without_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_sms_inbox, source_id: '1234567890')
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio sms inbox. valid Regex (?-mix:^\\+\\d{1,15}\\z)']
)
end
it 'validates twilio whatsapp channel source_id' do
twilio_whatsapp_inbox = create(:channel_twilio_sms, medium: :whatsapp).inbox
contact = create(:contact)
valid_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890')
ci_character_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:+1234567890aaa')
ci_without_plus_in_source_id = build(:contact_inbox, contact: contact, inbox: twilio_whatsapp_inbox, source_id: 'whatsapp:1234567890')
expect(valid_source_id.valid?).to be(true)
expect(ci_character_in_source_id.valid?).to be(false)
expect(ci_character_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
)
expect(ci_without_plus_in_source_id.valid?).to be(false)
expect(ci_without_plus_in_source_id.errors.full_messages).to eq(
['Source invalid source id for twilio whatsapp inbox. valid Regex (?-mix:^whatsapp:\\+\\d{1,15}\\z)']
)
end
end
end
end

View File

@@ -0,0 +1,199 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe Contact do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
end
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
end
describe 'concerns' do
it_behaves_like 'avatarable'
end
context 'when prepare contact attributes before validation' do
it 'sets email to lowercase' do
contact = create(:contact, email: 'Test@test.com')
expect(contact.email).to eq('test@test.com')
expect(contact.contact_type).to eq('lead')
end
it 'sets email to nil when empty string' do
contact = create(:contact, email: '')
expect(contact.email).to be_nil
expect(contact.contact_type).to eq('visitor')
end
it 'sets custom_attributes to {} when nil' do
contact = create(:contact, custom_attributes: nil)
expect(contact.custom_attributes).to eq({})
end
it 'sets custom_attributes to {} when empty string' do
contact = create(:contact, custom_attributes: '')
expect(contact.custom_attributes).to eq({})
end
it 'sets additional_attributes to {} when nil' do
contact = create(:contact, additional_attributes: nil)
expect(contact.additional_attributes).to eq({})
end
it 'sets additional_attributes to {} when empty string' do
contact = create(:contact, additional_attributes: '')
expect(contact.additional_attributes).to eq({})
end
end
context 'when phone number format' do
it 'will throw error for existing invalid phone number' do
contact = create(:contact)
expect { contact.update!(phone_number: '123456789') }.to raise_error(ActiveRecord::RecordInvalid)
end
it 'updates phone number when adding valid phone number' do
contact = create(:contact)
expect(contact.update!(phone_number: '+12312312321')).to be true
expect(contact.phone_number).to eq '+12312312321'
end
end
context 'when email format' do
it 'will throw error for existing invalid email' do
contact = create(:contact)
expect { contact.update!(email: '<2324234234') }.to raise_error(ActiveRecord::RecordInvalid)
end
it 'updates email when adding valid email' do
contact = create(:contact)
expect(contact.update!(email: 'test@test.com')).to be true
expect(contact.email).to eq 'test@test.com'
end
end
context 'when city and country code passed in additional attributes' do
it 'updates location and country code' do
contact = create(:contact, additional_attributes: { city: 'New York', country: 'US' })
expect(contact.location).to eq 'New York'
expect(contact.country_code).to eq 'US'
end
end
context 'when a contact is created' do
it 'has contact type "visitor" by default' do
contact = create(:contact)
expect(contact.contact_type).to eq 'visitor'
end
it 'has contact type "lead" when email is present' do
contact = create(:contact, email: 'test@test.com')
expect(contact.contact_type).to eq 'lead'
end
it 'has contact type "lead" when contacted through a social channel' do
contact = create(:contact, additional_attributes: { social_facebook_user_id: '123' })
expect(contact.contact_type).to eq 'lead'
end
end
describe '.resolved_contacts' do
let(:account) { create(:account) }
context 'when crm_v2 feature flag is disabled' do
it 'returns contacts with email, phone_number, or identifier using feature flag value' do
# Create contacts with different attributes
contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
contact_with_identifier = create(:contact, account: account, identifier: 'user123', name: 'Bob Wilson')
contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
resolved = account.contacts.resolved_contacts(use_crm_v2: false)
expect(resolved).to include(contact_with_email, contact_with_phone, contact_with_identifier)
expect(resolved).not_to include(contact_without_details)
end
end
context 'when crm_v2 feature flag is enabled' do
it 'returns only contacts with contact_type lead' do
# Contact with email and phone - should be marked as lead
contact_with_details = create(:contact, account: account, email: 'customer@example.com', phone_number: '+1234567890', name: 'Customer One')
expect(contact_with_details.contact_type).to eq('lead')
# Contact without email/phone - should be marked as visitor
contact_without_details = create(:contact, account: account, name: 'Lead', email: nil, phone_number: nil)
expect(contact_without_details.contact_type).to eq('visitor')
# Force set contact_type to lead for testing
contact_without_details.update!(contact_type: 'lead')
resolved = account.contacts.resolved_contacts(use_crm_v2: true)
expect(resolved).to include(contact_with_details)
expect(resolved).to include(contact_without_details)
end
it 'includes all lead contacts regardless of email/phone presence' do
# Create a lead contact with only name
lead_contact = create(:contact, account: account, name: 'Test Lead')
lead_contact.update!(contact_type: 'lead')
# Create a customer contact
customer_contact = create(:contact, account: account, email: 'customer@test.com')
customer_contact.update!(contact_type: 'customer')
# Create a visitor contact
visitor_contact = create(:contact, account: account, name: 'Visitor')
expect(visitor_contact.contact_type).to eq('visitor')
resolved = account.contacts.resolved_contacts(use_crm_v2: true)
expect(resolved).to include(lead_contact)
expect(resolved).not_to include(customer_contact)
expect(resolved).not_to include(visitor_contact)
end
it 'returns contacts with email, phone_number, or identifier when explicitly passing use_crm_v2: false' do
# Even though feature flag is enabled, we're explicitly passing false
contact_with_email = create(:contact, account: account, email: 'test@example.com', name: 'John Doe')
contact_with_phone = create(:contact, account: account, phone_number: '+1234567890', name: 'Jane Smith')
contact_without_details = create(:contact, account: account, name: 'Alice Johnson', email: nil, phone_number: nil, identifier: nil)
resolved = account.contacts.resolved_contacts(use_crm_v2: false)
# Should use the old logic despite feature flag being enabled
expect(resolved).to include(contact_with_email, contact_with_phone)
expect(resolved).not_to include(contact_without_details)
end
end
context 'with mixed contact types' do
it 'correctly filters based on use_crm_v2 parameter regardless of feature flag' do
# Create different types of contacts
visitor_contact = create(:contact, account: account, name: 'Visitor')
lead_with_email = create(:contact, account: account, email: 'lead@example.com', name: 'Lead')
lead_without_email = create(:contact, account: account, name: 'Lead Only')
lead_without_email.update!(contact_type: 'lead')
customer_contact = create(:contact, account: account, email: 'customer@example.com', name: 'Customer')
customer_contact.update!(contact_type: 'customer')
# Test with use_crm_v2: false
resolved_old = account.contacts.resolved_contacts(use_crm_v2: false)
expect(resolved_old).to include(lead_with_email, customer_contact)
expect(resolved_old).not_to include(visitor_contact, lead_without_email)
# Test with use_crm_v2: true
resolved_new = account.contacts.resolved_contacts(use_crm_v2: true)
expect(resolved_new).to include(lead_with_email, lead_without_email)
expect(resolved_new).not_to include(visitor_contact, customer_contact)
end
end
end
end

View File

@@ -0,0 +1,32 @@
require 'rails_helper'
RSpec.describe ConversationParticipant do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:user_id) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
it { is_expected.to belong_to(:user) }
end
describe 'validations' do
it 'ensure account is present' do
conversation = create(:conversation)
conversation_participant = build(:conversation_participant, conversation: conversation, account_id: nil)
conversation_participant.valid?
expect(conversation_participant.account_id).to eq(conversation.account_id)
end
it 'throws error if inbox member does not belongs to account' do
conversation = create(:conversation)
user = create(:user, account: conversation.account)
participant = build(:conversation_participant, user: user, conversation: conversation)
expect { participant.save! }.to raise_error(ActiveRecord::RecordInvalid)
expect(participant.errors.messages[:user]).to eq(['must have inbox access'])
end
end
end

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,28 @@
require 'rails_helper'
RSpec.describe CsatSurveyResponse do
describe 'validations' do
it { is_expected.to validate_presence_of(:rating) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:contact_id) }
it 'validates that the rating can only be in range 1-5' do
csat_survey_response = build(:csat_survey_response, rating: 6)
expect(csat_survey_response.valid?).to be false
end
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:conversation) }
it { is_expected.to belong_to(:contact) }
end
describe 'validates_factory' do
it 'creates valid csat_survey_response object' do
csat_survey_response = create(:csat_survey_response)
expect(csat_survey_response.valid?).to be true
end
end
end

View File

@@ -0,0 +1,23 @@
require 'rails_helper'
RSpec.describe DataImport do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it 'returns false for invalid data type' do
expect(build(:data_import, data_type: 'Xyc').valid?).to be false
end
end
describe 'callbacks' do
let(:data_import) { build(:data_import) }
it 'schedules a job after creation' do
expect do
data_import.save
end.to have_enqueued_job(DataImportJob).with(data_import).on_queue('low')
end
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe 'Conversation Audit', type: :model do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
# Enable auditing for conversations
conversation.class.send(:include, Enterprise::Audit::Conversation) if defined?(Enterprise::Audit::Conversation)
end
describe 'audit logging on destroy' do
it 'creates an audit log when conversation is destroyed' do
skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
expect do
conversation.destroy!
end.to change(Audited::Audit, :count).by(1)
audit = Audited::Audit.last
expect(audit.auditable_type).to eq('Conversation')
expect(audit.action).to eq('destroy')
expect(audit.auditable_id).to eq(conversation.id)
end
it 'does not create audit log for other actions by default' do
skip 'Enterprise audit module not available' unless defined?(Enterprise::Audit::Conversation)
expect do
conversation.update!(priority: 'high')
end.not_to(change(Audited::Audit, :count))
end
end
end

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
RSpec.describe Folder do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:category_id) }
it { is_expected.to validate_presence_of(:name) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:category) }
it { is_expected.to have_many(:articles) }
end
end

View File

@@ -0,0 +1,21 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InboxMember do
include ActiveJob::TestHelper
describe '#DestroyAssociationAsyncJob' do
let(:inbox_member) { create(:inbox_member) }
# ref: https://github.com/chatwoot/chatwoot/issues/4616
context 'when parent inbox is destroyed' do
it 'enques and processes DestroyAssociationAsyncJob' do
perform_enqueued_jobs do
inbox_member.inbox.destroy!
end
expect { inbox_member.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end

View File

@@ -0,0 +1,391 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/out_of_offisable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe Inbox do
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:channel) }
it { is_expected.to have_many(:contact_inboxes).dependent(:destroy_async) }
it { is_expected.to have_many(:contacts).through(:contact_inboxes) }
it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) }
it { is_expected.to have_many(:members).through(:inbox_members).source(:user) }
it { is_expected.to have_many(:conversations).dependent(:destroy_async) }
it { is_expected.to have_many(:messages).dependent(:destroy_async) }
it { is_expected.to have_one(:agent_bot_inbox) }
it { is_expected.to have_many(:webhooks).dependent(:destroy_async) }
it { is_expected.to have_many(:reporting_events) }
it { is_expected.to have_many(:hooks) }
end
describe 'concerns' do
it_behaves_like 'out_of_offisable'
it_behaves_like 'avatarable'
end
describe '#add_members' do
let(:inbox) { FactoryBot.create(:inbox) }
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'handles adds all members and resets cache keys' do
users = FactoryBot.create_list(:user, 3)
inbox.add_members(users.map(&:id))
expect(inbox.reload.inbox_members.size).to eq(3)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
.with(
'account.cache_invalidated',
kind_of(Time),
account: inbox.account,
cache_keys: inbox.account.cache_keys
)
end
end
describe '#remove_members' do
let(:inbox) { FactoryBot.create(:inbox) }
let(:users) { FactoryBot.create_list(:user, 3) }
before do
inbox.add_members(users.map(&:id))
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'removes the members and resets cache keys' do
expect(inbox.reload.inbox_members.size).to eq(3)
inbox.remove_members(users.map(&:id))
expect(inbox.reload.inbox_members.size).to eq(0)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
.with(
'account.cache_invalidated',
kind_of(Time),
account: inbox.account,
cache_keys: inbox.account.cache_keys
)
end
end
describe '#facebook?' do
let(:inbox) do
FactoryBot.build(:inbox, channel: channel_val)
end
context 'when the channel type is Channel::FacebookPage' do
let(:channel_val) { Channel::FacebookPage.new }
it do
expect(inbox.facebook?).to be(true)
expect(inbox.inbox_type).to eq('Facebook')
end
end
context 'when the channel type is not Channel::FacebookPage' do
let(:channel_val) { Channel::WebWidget.new }
it do
expect(inbox.facebook?).to be(false)
expect(inbox.inbox_type).to eq('Website')
end
end
end
describe '#web_widget?' do
let(:inbox) do
FactoryBot.build(:inbox, channel: channel_val)
end
context 'when the channel type is Channel::WebWidget' do
let(:channel_val) { Channel::WebWidget.new }
it do
expect(inbox.web_widget?).to be(true)
expect(inbox.inbox_type).to eq('Website')
end
end
context 'when the channel is not Channel::WebWidget' do
let(:channel_val) { Channel::Api.new }
it do
expect(inbox.web_widget?).to be(false)
expect(inbox.inbox_type).to eq('API')
end
end
end
describe '#api?' do
let(:inbox) do
FactoryBot.build(:inbox, channel: channel_val)
end
context 'when the channel type is Channel::Api' do
let(:channel_val) { Channel::Api.new }
it do
expect(inbox.api?).to be(true)
expect(inbox.inbox_type).to eq('API')
end
end
context 'when the channel is not Channel::Api' do
let(:channel_val) { Channel::FacebookPage.new }
it do
expect(inbox.api?).to be(false)
expect(inbox.inbox_type).to eq('Facebook')
end
end
end
describe '#validations' do
let(:inbox) { FactoryBot.create(:inbox) }
context 'when validating inbox name' do
it 'does not allow empty string' do
inbox.name = ''
expect(inbox).not_to be_valid
expect(inbox.errors.full_messages[0]).to eq(
"Name can't be blank"
)
end
it 'does allow special characters except /\@<> in between' do
inbox.name = 'inbox-name'
expect(inbox).to be_valid
inbox.name = 'inbox_name.and_1'
expect(inbox).to be_valid
end
context 'when special characters allowed for some channel' do
let!(:tw_channel_val) { FactoryBot.create(:channel_twitter_profile) }
let(:inbox) { create(:inbox, channel: tw_channel_val) }
it 'does allow special chacters like /\@<> for Facebook Channel' do
inbox.name = 'inbox@name'
expect(inbox).to be_valid
end
end
end
end
describe '#update' do
let!(:inbox) { FactoryBot.create(:inbox) }
let!(:portal) { FactoryBot.create(:portal) }
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'set portal id in inbox' do
inbox.portal_id = portal.id
inbox.save
expect(inbox.portal).to eq(portal)
end
it 'sends the inbox_created event if ENABLE_INBOX_EVENTS is true' do
with_modified_env ENABLE_INBOX_EVENTS: 'true' do
channel = inbox.channel
channel.update(widget_color: '#fff')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(
'inbox.updated',
kind_of(Time),
inbox: inbox,
changed_attributes: kind_of(Object)
)
end
end
it 'sends the inbox_created event if ENABLE_INBOX_EVENTS is false' do
channel = inbox.channel
channel.update(widget_color: '#fff')
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
.with(
'inbox.updated',
kind_of(Time),
inbox: inbox,
changed_attributes: kind_of(Object)
)
end
it 'resets cache key if there is an update in the channel' do
channel = inbox.channel
channel.update(widget_color: '#fff')
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
.with(
'account.cache_invalidated',
kind_of(Time),
account: inbox.account,
cache_keys: inbox.account.cache_keys
)
end
it 'updates the cache key after update' do
expect(inbox.account).to receive(:update_cache_key).with('inbox')
inbox.update(name: 'New Name')
end
it 'updates the cache key after touch' do
expect(inbox.account).to receive(:update_cache_key).with('inbox')
inbox.touch # rubocop:disable Rails/SkipsModelValidations
end
end
describe '#sanitized_name' do
context 'when inbox name contains forbidden characters' do
it 'removes forbidden and spam-trigger characters' do
inbox = FactoryBot.build(:inbox, name: 'Test/Name\\With<Bad>@Characters"And\';:Quotes!#$%')
expect(inbox.sanitized_name).to eq('Test/NameWithBadCharactersAnd\'Quotes')
end
end
context 'when inbox name has leading/trailing non-word characters' do
it 'removes leading and trailing symbols' do
inbox = FactoryBot.build(:inbox, name: '!!!Test Name***')
expect(inbox.sanitized_name).to eq('Test Name')
end
it 'handles mixed leading/trailing characters' do
inbox = FactoryBot.build(:inbox, name: '###@@@Test Inbox Name$$$%%')
expect(inbox.sanitized_name).to eq('Test Inbox Name')
end
end
context 'when inbox name has multiple spaces' do
it 'normalizes multiple spaces to single space' do
inbox = FactoryBot.build(:inbox, name: 'Test Multiple Spaces')
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
end
it 'handles tabs and other whitespace' do
inbox = FactoryBot.build(:inbox, name: "Test\t\nMultiple\r\nSpaces")
expect(inbox.sanitized_name).to eq('Test Multiple Spaces')
end
end
context 'when inbox name has leading/trailing whitespace' do
it 'strips whitespace' do
inbox = FactoryBot.build(:inbox, name: ' Test Name ')
expect(inbox.sanitized_name).to eq('Test Name')
end
end
context 'when inbox name becomes empty after sanitization' do
context 'with email channel' do
it 'falls back to email local part' do
email_channel = FactoryBot.build(:channel_email, email: 'support@example.com')
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: email_channel)
expect(inbox.sanitized_name).to eq('Support')
end
it 'handles email with complex local part' do
email_channel = FactoryBot.build(:channel_email, email: 'help-desk_team@example.com')
inbox = FactoryBot.build(:inbox, name: '!!!@@@', channel: email_channel)
expect(inbox.sanitized_name).to eq('Help Desk Team')
end
end
context 'with non-email channel' do
it 'returns empty string when name becomes blank' do
web_widget_channel = FactoryBot.build(:channel_widget)
inbox = FactoryBot.build(:inbox, name: '\\<>@"', channel: web_widget_channel)
expect(inbox.sanitized_name).to eq('')
end
end
end
context 'when inbox name is blank initially' do
context 'with email channel' do
it 'uses email local part as fallback' do
email_channel = FactoryBot.build(:channel_email, email: 'customer-care@example.com')
inbox = FactoryBot.build(:inbox, name: '', channel: email_channel)
expect(inbox.sanitized_name).to eq('Customer Care')
end
end
context 'with non-email channel' do
it 'returns empty string' do
api_channel = FactoryBot.build(:channel_api)
inbox = FactoryBot.build(:inbox, name: '', channel: api_channel)
expect(inbox.sanitized_name).to eq('')
end
end
end
context 'when inbox name contains valid characters' do
it 'preserves valid characters like hyphens, underscores, and dots' do
inbox = FactoryBot.build(:inbox, name: 'Test-Name_With.Valid-Characters')
expect(inbox.sanitized_name).to eq('Test-Name_With.Valid-Characters')
end
it 'preserves alphanumeric characters and spaces' do
inbox = FactoryBot.build(:inbox, name: 'Customer Support 123')
expect(inbox.sanitized_name).to eq('Customer Support 123')
end
it 'preserves balanced safe characters but removes spam-trigger symbols' do
inbox = FactoryBot.build(:inbox, name: "Test!#$%&'*+/=?^_`{|}~-Name")
expect(inbox.sanitized_name).to eq("Test'/_-Name")
end
it 'keeps commonly used safe characters' do
inbox = FactoryBot.build(:inbox, name: "Support/Help's Team.Desk_2024-Main")
expect(inbox.sanitized_name).to eq("Support/Help's Team.Desk_2024-Main")
end
end
context 'when inbox name contains problematic characters for email headers' do
it 'preserves Unicode symbols (trademark, etc.)' do
inbox = FactoryBot.build(:inbox, name: 'Test™Name®With©Special™Characters')
expect(inbox.sanitized_name).to eq('Test™Name®With©Special™Characters')
end
end
context 'with edge cases' do
it 'handles nil name gracefully' do
inbox = FactoryBot.build(:inbox)
allow(inbox).to receive(:name).and_return(nil)
expect { inbox.sanitized_name }.not_to raise_error
end
it 'handles very long names' do
long_name = 'A' * 1000
inbox = FactoryBot.build(:inbox, name: long_name)
expect(inbox.sanitized_name).to eq(long_name)
end
it 'handles unicode characters and preserves emojis' do
inbox = FactoryBot.build(:inbox, name: 'Test Name with émojis 🎉')
expect(inbox.sanitized_name).to eq('Test Name with émojis 🎉')
end
end
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe InstallationConfig do
it { is_expected.to validate_presence_of(:name) }
end

View File

@@ -0,0 +1,126 @@
require 'rails_helper'
RSpec.describe Integrations::App do
let(:apps) { described_class }
let(:app) { apps.find(id: app_name) }
let(:account) { create(:account) }
describe '#name' do
let(:app_name) { 'slack' }
it 'returns the name' do
expect(app.name).to eq('Slack')
end
end
describe '#logo' do
let(:app_name) { 'slack' }
it 'returns the logo' do
expect(app.logo).to eq('slack.png')
end
end
describe '#action' do
let(:app_name) { 'slack' }
before do
allow(Current).to receive(:account).and_return(account)
end
context 'when the app is slack' do
it 'returns the action URL with client_id and redirect_uri' do
with_modified_env SLACK_CLIENT_ID: 'dummy_client_id' do
expect(app.action).to include('client_id=dummy_client_id')
expect(app.action).to include(
"/app/accounts/#{account.id}/settings/integrations/slack"
)
end
end
end
end
describe '#active?' do
let(:app_name) { 'slack' }
context 'when the app is slack' do
it 'returns true if SLACK_CLIENT_SECRET is present' do
with_modified_env SLACK_CLIENT_SECRET: 'random_secret' do
expect(app.active?(account)).to be true
end
end
end
context 'when the app is shopify' do
let(:app_name) { 'shopify' }
it 'returns true if the shopify integration feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
it 'returns false if the shopify integration feature is disabled' do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be false
end
it 'returns false if SHOPIFY_CLIENT_ID is not present, even if feature is enabled' do
account.enable_features('shopify_integration')
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil).and_return(nil)
expect(app.active?(account)).to be false
end
end
context 'when the app is linear' do
let(:app_name) { 'linear' }
it 'returns false if the linear integration feature is disabled' do
expect(app.active?(account)).to be false
end
it 'returns true if the linear integration feature is enabled' do
account.enable_features('linear_integration')
account.save!
allow(GlobalConfigService).to receive(:load).with('LINEAR_CLIENT_ID', nil).and_return('client_id')
expect(app.active?(account)).to be true
end
end
context 'when other apps are queried' do
let(:app_name) { 'webhook' }
it 'returns true' do
expect(app.active?(account)).to be true
end
end
end
describe '#enabled?' do
context 'when the app is webhook' do
let(:app_name) { 'webhook' }
it 'returns false if the account does not have any webhooks' do
expect(app.enabled?(account)).to be false
end
it 'returns true if the account has webhooks' do
create(:webhook, account: account)
expect(app.enabled?(account)).to be true
end
end
context 'when the app is anything other than webhook' do
let(:app_name) { 'openai' }
it 'returns false if the account does not have any hooks for the app' do
expect(app.enabled?(account)).to be false
end
it 'returns true if the account has hooks for the app' do
create(:integrations_hook, :openai, account: account)
expect(app.enabled?(account)).to be true
end
end
end
end

View File

@@ -0,0 +1,114 @@
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/reauthorizable_shared.rb'
RSpec.describe Integrations::Hook do
it_behaves_like 'reauthorizable'
context 'with validations' do
it { is_expected.to validate_presence_of(:app_id) }
it { is_expected.to validate_presence_of(:account_id) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'when trying to create multiple hooks for an app' do
let(:account) { create(:account) }
context 'when app allows multiple hooks' do
it 'allows to create succesfully' do
create(:integrations_hook, account: account, app_id: 'webhook')
expect(build(:integrations_hook, account: account, app_id: 'webhook').valid?).to be true
end
end
context 'when app doesnot allow multiple hooks' do
it 'throws invalid error' do
create(:integrations_hook, account: account, app_id: 'slack')
expect(build(:integrations_hook, account: account, app_id: 'slack').valid?).to be false
end
end
end
describe 'scopes' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let!(:account_hook) { create(:integrations_hook, account: account, app_id: 'webhook') }
let!(:inbox_hook) do
create(:integrations_hook,
account: account,
app_id: 'dialogflow',
inbox: inbox,
settings: {
project_id: 'test-project',
credentials: { type: 'service_account' }
})
end
it 'returns account hooks' do
expect(described_class.account_hooks.pluck(:id)).to include(account_hook.id)
expect(described_class.account_hooks.pluck(:id)).not_to include(inbox_hook.id)
end
it 'returns inbox hooks' do
expect(described_class.inbox_hooks.pluck(:id)).to include(inbox_hook.id)
expect(described_class.inbox_hooks.pluck(:id)).not_to include(account_hook.id)
end
end
describe '#crm_integration?' do
let(:account) { create(:account) }
before do
account.enable_features('crm_integration')
end
it 'returns true for leadsquared integration' do
hook = create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test',
secret_key: 'test',
endpoint_url: 'https://api.leadsquared.com'
})
expect(hook.send(:crm_integration?)).to be true
end
it 'returns false for non-crm integrations' do
hook = create(:integrations_hook, account: account, app_id: 'slack')
expect(hook.send(:crm_integration?)).to be false
end
end
describe '#trigger_setup_if_crm' do
let(:account) { create(:account) }
before do
account.enable_features('crm_integration')
allow(Crm::SetupJob).to receive(:perform_later)
end
context 'when integration is a CRM' do
it 'enqueues setup job' do
create(:integrations_hook,
account: account,
app_id: 'leadsquared',
settings: {
access_key: 'test',
secret_key: 'test',
endpoint_url: 'https://api.leadsquared.com'
})
expect(Crm::SetupJob).to have_received(:perform_later)
end
end
context 'when integration is not a CRM' do
it 'does not enqueue setup job' do
create(:integrations_hook, account: account, app_id: 'slack')
expect(Crm::SetupJob).not_to have_received(:perform_later)
end
end
end
end

View File

@@ -0,0 +1,58 @@
require 'rails_helper'
RSpec.describe Label do
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'title validations' do
it 'would not let you start title without numbers or letters' do
label = FactoryBot.build(:label, title: '_12')
expect(label.valid?).to be false
end
it 'would not let you use special characters' do
label = FactoryBot.build(:label, title: 'jell;;2_12')
expect(label.valid?).to be false
end
it 'would not allow space' do
label = FactoryBot.build(:label, title: 'heeloo _12')
expect(label.valid?).to be false
end
it 'allows foreign charactes' do
label = FactoryBot.build(:label, title: '学中文_12')
expect(label.valid?).to be true
end
it 'converts uppercase letters to lowercase' do
label = FactoryBot.build(:label, title: 'Hello_World')
expect(label.valid?).to be true
expect(label.title).to eq 'hello_world'
end
it 'validates uniqueness of label name for account' do
account = create(:account)
label = FactoryBot.create(:label, account: account)
duplicate_label = FactoryBot.build(:label, title: label.title, account: account)
expect(duplicate_label.valid?).to be false
end
end
describe '.after_update_commit' do
let(:label) { create(:label) }
it 'calls update job' do
expect(Labels::UpdateJob).to receive(:perform_later).with('new-title', label.title, label.account_id)
label.update(title: 'new-title')
end
it 'does not call update job if title is not updated' do
expect(Labels::UpdateJob).not_to receive(:perform_later)
label.update(description: 'new-description')
end
end
end

View File

@@ -0,0 +1,120 @@
require 'rails_helper'
RSpec.describe Macro do
let(:account) { create(:account) }
let(:admin) { create(:user, account: account, role: :administrator) }
after do
Current.user = nil
Current.account = nil
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
describe 'validations' do
it 'validation action name' do
macro = FactoryBot.build(:macro, account: account, created_by: admin, updated_by: admin, actions: [{ action_name: :update_last_seen }])
expect(macro).not_to be_valid
expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.'])
end
end
describe '#set_visibility' do
let(:agent) { create(:user, account: account, role: :agent) }
let(:macro) { create(:macro, account: account, created_by: admin, updated_by: admin, actions: []) }
context 'when user is administrator' do
it 'set visibility with params' do
expect(macro.visibility).to eq('personal')
macro.set_visibility(admin, { visibility: :global })
expect(macro.visibility).to eq('global')
macro.set_visibility(admin, { visibility: :personal })
expect(macro.visibility).to eq('personal')
end
end
context 'when user is agent' do
it 'set visibility always to agent' do
Current.user = agent
Current.account = account
expect(macro.visibility).to eq('personal')
macro.set_visibility(agent, { visibility: :global })
expect(macro.visibility).to eq('personal')
end
end
end
describe '#with_visibility' do
let(:agent_1) { create(:user, account: account, role: :agent) }
let(:agent_2) { create(:user, account: account, role: :agent) }
before do
create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global, actions: [])
create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :global, actions: [])
create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal, actions: [])
create(:macro, account: account, created_by: admin, updated_by: admin, visibility: :personal, actions: [])
create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal, actions: [])
create(:macro, account: account, created_by: agent_1, updated_by: agent_1, visibility: :personal, actions: [])
create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: [])
create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: [])
create(:macro, account: account, created_by: agent_2, updated_by: agent_2, visibility: :personal, actions: [])
end
context 'when user is administrator' do
it 'return all macros in account' do
Current.user = admin
Current.account = account
macros = account.macros.global.or(account.macros.personal.where(created_by_id: admin.id))
expect(described_class.with_visibility(admin, {}).count).to eq(macros.count)
end
end
context 'when user is agent' do
it 'return all macros in account and created_by user' do
Current.user = agent_2
Current.account = account
macros_for_agent_2 = account.macros.global.count + agent_2.macros.personal.count
expect(described_class.with_visibility(agent_2, {}).count).to eq(macros_for_agent_2)
Current.user = agent_1
macros_for_agent_1 = account.macros.global.count + agent_1.macros.personal.count
expect(described_class.with_visibility(agent_1, {}).count).to eq(macros_for_agent_1)
end
end
end
describe '#associations' do
let(:agent) { create(:user, account: account, role: :agent) }
let!(:global_macro) { FactoryBot.create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :global, actions: []) }
let!(:personal_macro) { FactoryBot.create(:macro, account: account, created_by: agent, updated_by: agent, visibility: :personal, actions: []) }
context 'when you delete the author' do
it 'nullify the created_by column' do
expect(global_macro.created_by).to eq(agent)
expect(global_macro.updated_by).to eq(agent)
expect(personal_macro.created_by).to eq(agent)
expect(personal_macro.updated_by).to eq(agent)
personal_macro_id = personal_macro.id
agent.destroy!
expect(global_macro.reload.created_by).to be_nil
expect(global_macro.reload.updated_by).to be_nil
expect(described_class.find_by(id: personal_macro_id)).to be_nil
end
end
end
end

View File

@@ -0,0 +1,67 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Mention do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user) }
it { is_expected.to belong_to(:conversation) }
end
describe 'Custom Sort' do
let!(:account) { create(:account) }
let!(:user_1) { create(:user, email: 'agent2@example.com', account: account) }
let!(:user_2) { create(:user, email: 'agent11@example.com', account: account) }
let!(:conversation_1) { create(:conversation, created_at: DateTime.now - 8.days) }
let!(:conversation_2) { create(:conversation, created_at: DateTime.now - 6.days) }
let!(:conversation_3) { create(:conversation, created_at: DateTime.now - 9.days) }
let!(:conversation_4) { create(:conversation, created_at: DateTime.now - 10.days) }
let!(:mention_1) { create(:mention, account: account, conversation: conversation_1, user: user_1) }
let!(:mention_2) { create(:mention, account: account, conversation: conversation_2, user: user_1) }
let!(:mention_3) { create(:mention, account: account, conversation: conversation_3, user: user_1) }
it 'Sort mentioned conversations based on created_at' do
records = described_class.sort_on_created_at
expect(records.first.id).to eq(mention_1.id)
expect(records.first.conversation_id).to eq(conversation_1.id)
expect(records.last.conversation_id).to eq(conversation_3.id)
end
it 'Sort mentioned conversations based on last_user_message_at' do
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now - 2.days)
create(:message, conversation_id: conversation_2.id, message_type: :incoming, created_at: DateTime.now - 6.days)
create(:message, conversation_id: conversation_2.id, message_type: :incoming, created_at: DateTime.now - 6.days)
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now - 6.days)
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now - 6.days)
create(:message, conversation_id: conversation_1.id, message_type: :outgoing, created_at: DateTime.now - 7.days)
create(:message, conversation_id: conversation_1.id, message_type: :incoming, created_at: DateTime.now - 8.days)
create(:message, conversation_id: conversation_1.id, message_type: :incoming, created_at: DateTime.now - 8.days)
create(:message, conversation_id: conversation_3.id, message_type: :outgoing, created_at: DateTime.now - 9.days)
records = described_class.last_user_message_at
expect(records.first.id).to eq(mention_2.id)
expect(records.first.conversation_id).to eq(conversation_2.id)
expect(records.last.conversation_id).to eq(conversation_1.id)
expect(records.pluck(:id)).not_to include(conversation_4.id)
end
it 'Sort conversations based on mentioned_at' do
records = described_class.latest
expect(records.first.id).to eq(mention_3.id)
expect(records.first.conversation_id).to eq(conversation_3.id)
expect(records.last.conversation_id).to eq(conversation_1.id)
travel_to DateTime.now + 1.day
mention = create(:mention, account: account, conversation: conversation_2, user: user_2)
records = described_class.latest
expect(records.first.conversation_id).to eq(conversation_2.id)
expect(mention.created_at).to eq(DateTime.now)
end
end
end

View File

@@ -0,0 +1,845 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/liquidable_shared.rb'
RSpec.describe Message do
before do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
end
context 'with validations' do
it { is_expected.to validate_presence_of(:inbox_id) }
it { is_expected.to validate_presence_of(:conversation_id) }
it { is_expected.to validate_presence_of(:account_id) }
end
describe 'length validations' do
let!(:message) { create(:message) }
context 'when it validates name length' do
it 'valid when within limit' do
message.content = 'a' * 120_000
expect(message.valid?).to be true
end
it 'invalid when crossed the limit' do
message.content = 'a' * 150_001
message.processed_message_content = 'a' * 150_001
message.valid?
expect(message.errors[:processed_message_content]).to include('is too long (maximum is 150000 characters)')
expect(message.errors[:content]).to include('is too long (maximum is 150000 characters)')
end
it 'adds error in case of message flooding' do
with_modified_env 'CONVERSATION_MESSAGE_PER_MINUTE_LIMIT': '2' do
conversation = message.conversation
create(:message, conversation: conversation)
conv_new_message = build(:message, conversation: message.conversation)
expect(conv_new_message.valid?).to be false
expect(conv_new_message.errors[:base]).to eq(['Too many messages'])
end
end
end
context 'when it validates source_id length' do
it 'valid when source_id is within text limit (20000 chars)' do
long_source_id = 'a' * 10_000
message.source_id = long_source_id
expect(message.valid?).to be true
end
it 'valid when source_id is exactly 20000 characters' do
long_source_id = 'a' * 20_000
message.source_id = long_source_id
expect(message.valid?).to be true
end
it 'invalid when source_id exceeds text limit (20000 chars)' do
long_source_id = 'a' * 20_001
message.source_id = long_source_id
message.valid?
expect(message.errors[:source_id]).to include('is too long (maximum is 20000 characters)')
end
it 'handles long email Message-ID headers correctly' do
# Simulate a long Message-ID like some email systems generate
long_message_id = "msg-#{SecureRandom.hex(240)}@verylongdomainname.example.com"[0...500]
message.source_id = long_message_id
message.content_type = 'incoming_email'
expect(message.valid?).to be true
expect(message.source_id.length).to eq(500)
end
it 'allows nil source_id' do
message.source_id = nil
expect(message.valid?).to be true
end
it 'allows empty string source_id' do
message.source_id = ''
expect(message.valid?).to be true
end
end
end
describe 'concerns' do
it_behaves_like 'liqudable'
end
describe 'message_filter_helpers' do
context 'when webhook_sendable?' do
[
{ type: :incoming, expected: true },
{ type: :outgoing, expected: true },
{ type: :template, expected: true },
{ type: :activity, expected: false }
].each do |scenario|
it "returns #{scenario[:expected]} for #{scenario[:type]} message" do
message = create(:message, message_type: scenario[:type])
expect(message.webhook_sendable?).to eq(scenario[:expected])
end
end
end
end
describe '#push_event_data' do
subject(:push_event_data) { message.push_event_data }
let(:message) { create(:message, echo_id: 'random-echo_id') }
let(:expected_data) do
{
account_id: message.account_id,
additional_attributes: message.additional_attributes,
content_attributes: message.content_attributes,
content_type: message.content_type,
content: message.content,
conversation_id: message.conversation.display_id,
created_at: message.created_at.to_i,
external_source_ids: message.external_source_ids,
id: message.id,
inbox_id: message.inbox_id,
message_type: message.message_type_before_type_cast,
private: message.private,
processed_message_content: message.processed_message_content,
sender_id: message.sender_id,
sender_type: message.sender_type,
source_id: message.source_id,
status: message.status,
updated_at: message.updated_at,
conversation: {
assignee_id: message.conversation.assignee_id,
contact_inbox: {
source_id: message.conversation.contact_inbox.source_id
},
last_activity_at: message.conversation.last_activity_at.to_i,
unread_count: message.conversation.unread_incoming_messages.count
},
sentiment: {},
sender: message.sender.push_event_data,
echo_id: 'random-echo_id'
}
end
it 'returns push event payload' do
expect(push_event_data).to eq(expected_data)
end
end
describe 'message create event' do
let!(:conversation) { create(:conversation) }
before do
conversation.reload
end
it 'updates the conversation first reply created at if it is the first outgoing message' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
outgoing_message = create(:message, message_type: :outgoing, conversation: conversation)
expect(conversation.first_reply_created_at).to eq outgoing_message.created_at
expect(conversation.waiting_since).to be_nil
end
it 'does not update the conversation first reply created at if the message is incoming' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :incoming, conversation: conversation)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
end
it 'does not update the conversation first reply created at if the message is template' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :template, conversation: conversation)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
end
it 'does not update the conversation first reply created at if the message is activity' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :activity, conversation: conversation)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
end
it 'does not update the conversation first reply created at if the message is a private message' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :outgoing, conversation: conversation, private: true)
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
next_message = create(:message, message_type: :outgoing, conversation: conversation)
expect(conversation.first_reply_created_at).to eq next_message.created_at
expect(conversation.waiting_since).to be_nil
end
it 'does not update first reply if the message is sent as campaign' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :outgoing, conversation: conversation, additional_attributes: { campaign_id: 1 })
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
end
it 'does not update first reply if the message is sent by automation' do
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
create(:message, message_type: :outgoing, conversation: conversation, content_attributes: { automation_rule_id: 1 })
expect(conversation.first_reply_created_at).to be_nil
expect(conversation.waiting_since).to eq conversation.created_at
end
end
describe '#reopen_conversation' do
let(:conversation) { create(:conversation) }
let(:message) { build(:message, message_type: :incoming, conversation: conversation) }
it 'reopens resolved conversation when the message is from a contact' do
conversation.resolved!
message.save!
expect(message.conversation.open?).to be true
end
it 'reopens snoozed conversation when the message is from a contact' do
conversation.snoozed!
message.save!
expect(message.conversation.open?).to be true
end
it 'will not reopen if the conversation is muted' do
conversation.resolved!
conversation.mute!
message.save!
expect(message.conversation.open?).to be false
end
it 'will mark the conversation as pending if the agent bot is active' do
agent_bot = create(:agent_bot)
inbox = conversation.inbox
inbox.agent_bot = agent_bot
inbox.save!
conversation.resolved!
message.save!
expect(conversation.open?).to be false
expect(conversation.pending?).to be true
end
end
describe '#waiting since' do
let(:conversation) { create(:conversation) }
let(:agent) { create(:user, account: conversation.account) }
let(:message) { build(:message, conversation: conversation) }
it 'resets the waiting_since if an agent sent a reply' do
message.message_type = :outgoing
message.sender = agent
message.save!
expect(conversation.waiting_since).to be_nil
end
it 'sets the waiting_since if there is an incoming message' do
conversation.update(waiting_since: nil)
message.message_type = :incoming
message.save!
expect(conversation.waiting_since).not_to be_nil
end
it 'does not overwrite the previous value if there are newer messages' do
old_waiting_since = conversation.waiting_since
message.message_type = :incoming
message.save!
conversation.reload
expect(conversation.waiting_since).to eq old_waiting_since
end
context 'when bot has responded to the conversation' do
let(:agent_bot) { create(:agent_bot, account: conversation.account) }
before do
# Create initial customer message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 2.hours.ago)
conversation.update(waiting_since: 2.hours.ago)
# Bot responds
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 1.hour.ago)
end
it 'resets waiting_since when customer sends a new message after bot response' do
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'does not reset waiting_since if last response was from human agent' do
# Human agent responds (clears waiting_since)
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent)
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends new message
new_message = build(:message, conversation: conversation, message_type: :incoming)
new_message.save!
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(new_message.created_at)
end
it 'clears waiting_since when bot responds' do
# After the bot response in before block, waiting_since should already be cleared
conversation.reload
expect(conversation.waiting_since).to be_nil
# Customer sends another message
create(:message, conversation: conversation, message_type: :incoming,
created_at: 30.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_within(1.second).of(30.minutes.ago)
# Another bot response should clear it again
create(:message, conversation: conversation, message_type: :outgoing,
sender: agent_bot, created_at: 15.minutes.ago)
conversation.reload
expect(conversation.waiting_since).to be_nil
end
end
end
context 'with webhook_data' do
it 'contains the message attachment when attachment is present' do
message = create(:message)
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
attachment.save!
expect(message.webhook_data.key?(:attachments)).to be true
end
it 'does not contain the message attachment when attachment is not present' do
message = create(:message)
expect(message.webhook_data.key?(:attachments)).to be false
end
it 'uses outgoing_content for webhook content' do
message = create(:message, content: 'Test content')
expect(message).to receive(:outgoing_content).and_return('Outgoing test content')
webhook_data = message.webhook_data
expect(webhook_data[:content]).to eq('Outgoing test content')
end
it 'includes CSAT survey link in webhook content for input_csat messages' do
inbox = create(:inbox, channel: create(:channel_api))
conversation = create(:conversation, inbox: inbox)
message = create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience')
expect(message.outgoing_content).to include('survey/responses/')
expect(message.webhook_data[:content]).to include('survey/responses/')
end
end
context 'when message is created' do
let(:message) { build(:message, account: create(:account)) }
it 'updates conversation last_activity_at when created' do
message.save!
expect(message.created_at).to eq message.conversation.last_activity_at
end
it 'updates contact last_activity_at when created' do
expect { message.save! }.to(change { message.sender.last_activity_at })
end
it 'triggers ::MessageTemplates::HookExecutionService' do
hook_execution_service = double
allow(MessageTemplates::HookExecutionService).to receive(:new).and_return(hook_execution_service)
allow(hook_execution_service).to receive(:perform).and_return(true)
message.save!
expect(MessageTemplates::HookExecutionService).to have_received(:new).with(message: message)
expect(hook_execution_service).to have_received(:perform)
end
context 'with conversation continuity' do
let(:inbox_with_continuity) do
create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: true))
end
it 'schedules email notification for outgoing messages in website channel' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
ActiveJob::Base.queue_adapter = :test
allow(Redis::Alfred).to receive(:set).and_return(true)
perform_enqueued_jobs(only: SendReplyJob) do
expect { message.save! }.to have_enqueued_job(ConversationReplyEmailJob).with(message.conversation.id, kind_of(Integer)).on_queue('mailers')
end
end
it 'does not schedule email for website channel if continuity is disabled' do
inbox_without_continuity = create(:inbox, account: message.account,
channel: build(:channel_widget, account: message.account, continuity_via_email: false))
message.inbox = inbox_without_continuity
message.conversation.update!(inbox: inbox_without_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.message_type = 'outgoing'
ActiveJob::Base.queue_adapter = :test
expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
it 'does not schedule email for private notes' do
message.inbox = inbox_with_continuity
message.conversation.update!(inbox: inbox_with_continuity)
message.conversation.contact.update!(email: 'test@example.com')
message.private = true
message.message_type = 'outgoing'
ActiveJob::Base.queue_adapter = :test
expect { message.save! }.not_to have_enqueued_job(ConversationReplyEmailJob)
end
it 'calls SendReplyJob for all channels' do
allow(SendReplyJob).to receive(:perform_later).and_return(true)
message.message_type = 'outgoing'
message.save!
expect(SendReplyJob).to have_received(:perform_later).with(message.id)
end
end
end
context 'when content_type is blank' do
let(:message) { build(:message, content_type: nil, account: create(:account)) }
it 'sets content_type as text' do
message.save!
expect(message.content_type).to eq 'text'
end
end
context 'when processed_message_content is blank' do
let(:message) { build(:message, content_type: :text, account: create(:account), content: 'Processed message content') }
it 'sets content_type as text' do
message.save!
expect(message.processed_message_content).to eq message.content
end
end
context 'when attachments size maximum' do
let(:message) { build(:message, content_type: nil, account: create(:account)) }
it 'add errors to message for attachment size is more than allowed limit' do
16.times.each do
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
expect(message.errors.messages).to eq({ attachments: ['exceeded maximum allowed'] })
end
end
context 'when email notifiable message' do
let(:message) { build(:message, content_type: nil, account: create(:account)) }
it 'return false if private message' do
message.private = true
message.message_type = 'outgoing'
expect(message.email_notifiable_message?).to be false
end
it 'return false if incoming message' do
message.private = false
message.message_type = 'incoming'
expect(message.email_notifiable_message?).to be false
end
it 'return false if activity message' do
message.private = false
message.message_type = 'activity'
expect(message.email_notifiable_message?).to be false
end
it 'return false if message type is template and content type is not input_csat or text' do
message.private = false
message.message_type = 'template'
message.content_type = 'incoming_email'
expect(message.email_notifiable_message?).to be false
end
it 'return true if not private and not incoming and message content type is input_csat or text' do
message.private = false
message.message_type = 'template'
message.content_type = 'text'
expect(message.email_notifiable_message?).to be true
end
end
context 'when facebook channel with unavailable story link' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'keeps the attachment for deleted stories' do
expect(instagram_message.attachments.count).to eq 1
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 404)
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 1
end
it 'keeps the attachment for expired stories' do
expect(instagram_message.attachments.count).to eq 1
# for expired stories, the link will be empty
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: '', id: '17920786367196703' } }
}.to_json, headers: {})
instagram_message.push_event_data
expect(instagram_message.reload.attachments.count).to eq 1
end
end
describe '#ensure_in_reply_to' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, source_id: 12_345) }
context 'when in_reply_to is present' do
let(:content_attributes) { { in_reply_to: message.id } }
let(:new_message) { build(:message, conversation: conversation, content_attributes: content_attributes) }
it 'sets in_reply_to_external_id based on the source_id of the referenced message' do
new_message.send(:ensure_in_reply_to)
expect(new_message.content_attributes[:in_reply_to_external_id]).to eq(message.source_id)
end
end
context 'when in_reply_to is not present' do
let(:content_attributes) { { in_reply_to_external_id: message.source_id } }
let(:new_message) { build(:message, conversation: conversation, content_attributes: content_attributes) }
it 'sets in_reply_to based on the source_id of the referenced message' do
new_message.send(:ensure_in_reply_to)
expect(new_message.content_attributes[:in_reply_to]).to eq(message.id)
end
end
context 'when the referenced message is not found' do
let(:content_attributes) { { in_reply_to: message.id + 1 } }
let(:new_message) { build(:message, conversation: conversation, content_attributes: content_attributes) }
it 'does not set in_reply_to_external_id' do
new_message.send(:ensure_in_reply_to)
expect(new_message.content_attributes[:in_reply_to_external_id]).to be_nil
end
end
context 'when the source message is not found' do
let(:content_attributes) { { in_reply_to_external_id: 'source-id-that-does-not-exist' } }
let(:new_message) { build(:message, conversation: conversation, content_attributes: content_attributes) }
it 'does not set in_reply_to' do
new_message.send(:ensure_in_reply_to)
expect(new_message.content_attributes[:in_reply_to]).to be_nil
end
end
end
describe '#content' do
let(:conversation) { create(:conversation) }
context 'when message is not input_csat' do
let(:message) { create(:message, conversation: conversation, content_type: 'text', content: 'Regular message') }
it 'returns original content' do
expect(message.content).to eq('Regular message')
end
end
context 'when message is input_csat' do
let(:message) { create(:message, conversation: conversation, content_type: 'input_csat', content: 'Rate your experience') }
context 'when inbox is web widget' do
before do
allow(message.inbox).to receive(:web_widget?).and_return(true)
end
it 'returns original content without survey URL' do
expect(message.content).to eq('Rate your experience')
end
end
context 'when inbox is not web widget' do
before do
allow(message.inbox).to receive(:web_widget?).and_return(false)
end
it 'returns only the stored content (clean for dashboard)' do
expect(message.content).to eq('Rate your experience')
end
it 'returns only the base content without URL when survey_url stored separately' do
message.content_attributes = { 'survey_url' => 'https://app.chatwoot.com/survey/responses/12345' }
expect(message.content).to eq('Rate your experience')
end
end
end
end
describe '#outgoing_content' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, content_type: 'text', content: 'Regular message') }
it 'delegates to MessageContentPresenter' do
presenter = instance_double(MessageContentPresenter)
allow(MessageContentPresenter).to receive(:new).with(message).and_return(presenter)
allow(presenter).to receive(:outgoing_content).and_return('Presented content')
expect(message.outgoing_content).to eq('Presented content')
expect(MessageContentPresenter).to have_received(:new).with(message)
expect(presenter).to have_received(:outgoing_content)
end
end
describe '#auto_reply_email?' do
context 'when message is not an incoming email and inbox is not email' do
let(:conversation) { create(:conversation) }
let(:message) { create(:message, conversation: conversation, message_type: :outgoing) }
it 'returns false' do
expect(message.auto_reply_email?).to be false
end
end
context 'when message is an incoming email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :incoming,
content_type: 'incoming_email',
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
context 'when inbox is email' do
let(:email_channel) { create(:channel_email) }
let(:email_inbox) { create(:inbox, channel: email_channel) }
let(:conversation) { create(:conversation, inbox: email_inbox) }
it 'returns false when auto_reply is not set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: {}
)
expect(message.auto_reply_email?).to be false
end
it 'returns true when auto_reply is set to true' do
message = create(
:message,
conversation: conversation,
message_type: :outgoing,
content_attributes: { email: { auto_reply: true } }
)
expect(message.auto_reply_email?).to be true
end
end
end
describe '#should_index?' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:message) { create(:message, conversation: conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search_indexing')
end
context 'when advanced search is not allowed globally' do
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when advanced search feature is not enabled for account on chatwoot cloud' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when advanced search feature is not enabled for account on self-hosted' do
before do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(false)
account.disable_features('advanced_search_indexing')
end
it 'returns true' do
expect(message.should_index?).to be true
end
end
context 'when message type is not incoming or outgoing' do
before do
message.message_type = 'activity'
end
it 'returns false' do
expect(message.should_index?).to be false
end
end
context 'when all conditions are met' do
it 'returns true for incoming message' do
message.message_type = 'incoming'
expect(message.should_index?).to be true
end
it 'returns true for outgoing message' do
message.message_type = 'outgoing'
expect(message.should_index?).to be true
end
end
end
describe '#reindex_for_search callback' do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(true)
account.enable_features('advanced_search_indexing')
end
context 'when message should be indexed' do
it 'calls reindex_for_search for incoming message on create' do
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).to receive(:reindex_for_search)
message.save!
end
it 'calls reindex_for_search for outgoing message on update' do
# rubocop:disable RSpec/AnyInstance
allow_any_instance_of(described_class).to receive(:reindex_for_search).and_return(true)
# rubocop:enable RSpec/AnyInstance
message = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(message).to receive(:reindex_for_search).and_return(true)
message.update!(content: 'Updated content')
end
end
context 'when message should not be indexed' do
it 'does not call reindex_for_search for activity message' do
message = build(:message, conversation: conversation, account: account, message_type: :activity)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search for unpaid account on cloud' do
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
account.disable_features('advanced_search_indexing')
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
it 'does not call reindex_for_search when advanced search is not allowed' do
allow(ChatwootApp).to receive(:advanced_search_allowed?).and_return(false)
message = build(:message, conversation: conversation, account: account, message_type: :incoming)
expect(message).not_to receive(:reindex_for_search)
message.save!
end
end
end
end

View File

@@ -0,0 +1,22 @@
require 'rails_helper'
RSpec.describe Note do
describe 'validations' do
it { is_expected.to validate_presence_of(:content) }
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:contact_id) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:contact) }
end
describe 'validates_factory' do
it 'creates valid note object' do
note = create(:note)
expect(note.content).to eq 'Hey welcome to chatwoot'
end
end
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe NotificationSetting do
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user) }
end
end

View File

@@ -0,0 +1,201 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe Notification do
include ActiveJob::TestHelper
context 'with associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:user) }
end
context 'with default order by' do
it 'sort by primary id desc' do
notification1 = create(:notification)
create(:notification)
notification3 = create(:notification)
expect(described_class.all.first.id).to eq notification1.id
expect(described_class.all.last.id).to eq notification3.id
end
end
context 'when push_title is called' do
it 'returns appropriate title suited for the notification type conversation_creation' do
notification = create(:notification, notification_type: 'conversation_creation')
expect(notification.push_message_title).to eq "A conversation (##{notification.primary_actor.display_id}) \
has been created in #{notification.primary_actor.inbox.name}"
end
it 'returns appropriate title suited for the notification type conversation_assignment' do
notification = create(:notification, notification_type: 'conversation_assignment')
expect(notification.push_message_title).to eq "A conversation (##{notification.primary_actor.display_id}) \
has been assigned to you"
end
it 'returns appropriate title suited for the notification type assigned_conversation_new_message' do
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2))
notification = create(:notification, notification_type: 'assigned_conversation_new_message', primary_actor: message.conversation,
secondary_actor: message)
expect(notification.push_message_title).to eq "A new message is created in conversation (##{notification.primary_actor.display_id})"
end
it 'returns appropriate title suited for the notification type assigned_conversation_new_message when attachment message' do
# checking content nil should be suffice for attachments
message = create(:message, sender: create(:user), content: nil)
notification = create(:notification, notification_type: 'assigned_conversation_new_message', primary_actor: message.conversation,
secondary_actor: message)
expect(notification.push_message_title).to eq "A new message is created in conversation (##{notification.primary_actor.display_id})"
end
it 'returns appropriate title suited for the notification type participating_conversation_new_message' do
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2))
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: message.conversation,
secondary_actor: message)
expect(notification.push_message_title).to eq "A new message is created in conversation (##{notification.primary_actor.display_id})"
end
it 'returns appropriate title suited for the notification type conversation_mention' do
message = create(:message, sender: create(:user), content: 'Hey [@John](mention://user/1/john), can you check this ticket?')
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: message.conversation,
secondary_actor: message)
expect(notification.push_message_title).to eq "You have been mentioned in conversation (##{notification.primary_actor.display_id})"
end
end
context 'when push_message_body is called' do
it 'returns appropriate body suited for the notification type conversation_creation' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), conversation: conversation)
notification = create(:notification, notification_type: 'conversation_creation', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type conversation_assignment' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), conversation: conversation)
notification = create(:notification, notification_type: 'conversation_assignment', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type conversation_assignment with outgoing message only' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), message_type: :outgoing,
conversation: conversation)
notification = create(:notification, notification_type: 'conversation_assignment', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type assigned_conversation_new_message' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2), conversation: conversation)
notification = create(:notification, notification_type: 'assigned_conversation_new_message', primary_actor: conversation,
secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: #{message.content.truncate_words(10)}"
end
it 'returns appropriate body suited for the notification type assigned_conversation_new_message when attachment message' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: nil, conversation: conversation)
attachment = message.attachments.new(file_type: :image, account_id: message.account_id)
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
message.save!
notification = create(:notification, notification_type: 'assigned_conversation_new_message', primary_actor: conversation,
secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Attachment"
end
it 'returns appropriate body suited for the notification type participating_conversation_new_message having multple mention' do
conversation = create(:conversation)
message = create(:message, sender: create(:user),
content: 'Hey [@John](mention://user/1/john), [@Alisha Peter](mention://user/2/alisha) can you check this ticket?',
conversation: conversation)
notification = create(:notification, notification_type: 'participating_conversation_new_message', primary_actor: conversation,
secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John, @Alisha Peter can you check this ticket?"
end
it 'returns appropriate body suited for the notification type conversation_mention if username contains white space' do
conversation = create(:conversation)
message = create(:message, sender: create(:user), content: 'Hey [@John Peter](mention://user/1/john%20K) please check this?',
conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Peter please check this?"
end
it 'returns appropriate body suited for the notification type conversation_mention if username contains emoji' do
conversation = create(:conversation)
content = 'Hey [@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please check this?'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @👍 customer support please check this?"
end
it 'returns appropriate body suited for the notification type conversation_mention if team name contains emoji and spaces' do
conversation = create(:conversation)
content = 'Please check [@🚀 Development Team](mention://team/2/%F0%9F%9A%80%20Development%20Team)'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Please check @🚀 Development Team"
end
it 'returns appropriate body suited for the notification type conversation_mention with mixed emoji and regular mentions' do
conversation = create(:conversation)
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and ' \
'[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support) please review'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Hey @John Doe and @👍 customer support please review"
end
it 'returns appropriate body suited for the notification type conversation_mention with special characters in names' do
conversation = create(:conversation)
content = 'Please review [@user@domain.com](mention://user/4/user%40domain.com)'
message = create(:message, sender: create(:user), content: content, conversation: conversation)
notification = create(:notification, notification_type: 'conversation_mention', primary_actor: conversation, secondary_actor: message)
expect(notification.push_message_body).to eq "#{message.sender.name}: Please review @user@domain.com"
end
it 'calls remove duplicate notification job' do
allow(Notification::RemoveDuplicateNotificationJob).to receive(:perform_later)
notification = create(:notification, notification_type: 'conversation_mention')
expect(Notification::RemoveDuplicateNotificationJob).to have_received(:perform_later).with(notification)
end
end
context 'when fcm push data' do
it 'returns correct data for primary actor conversation' do
notification = create(:notification, notification_type: 'conversation_creation')
expect(notification.fcm_push_data[:primary_actor]).to eq({
'id' => notification.primary_actor.display_id
})
end
it 'returns correct data for primary actor message' do
message = create(:message, sender: create(:user), content: Faker::Lorem.paragraphs(number: 2))
notification = create(:notification, notification_type: 'assigned_conversation_new_message', primary_actor: message.conversation,
secondary_actor: message)
expect(notification.fcm_push_data[:primary_actor]).to eq({
'id' => notification.primary_actor.display_id
})
end
end
context 'when primary actor is deleted' do
let!(:conversation) { create(:conversation) }
it 'clears notifications' do
notification = create(:notification, notification_type: 'conversation_creation', primary_actor: conversation)
perform_enqueued_jobs do
conversation.inbox.destroy!
end
expect { notification.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end

View File

@@ -0,0 +1,20 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe PlatformAppPermissible do
let!(:platform_app_permissible) { create(:platform_app_permissible) }
context 'with validations' do
it { is_expected.to validate_presence_of(:platform_app) }
end
context 'with associations' do
it { is_expected.to belong_to(:platform_app) }
it { is_expected.to belong_to(:permissible) }
end
describe 'with factories' do
it { expect(platform_app_permissible).present? }
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
RSpec.describe PlatformApp do
let(:platform_app) { create(:platform_app) }
context 'with validations' do
it { is_expected.to validate_presence_of(:name) }
end
context 'with associations' do
it { is_expected.to have_many(:platform_app_permissibles) }
end
describe 'with concerns' do
it_behaves_like 'access_tokenable'
end
describe 'with factories' do
it { expect(platform_app).present? }
end
end

View File

@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Portal do
context 'with validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:slug) }
it { is_expected.to validate_presence_of(:name) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:categories) }
it { is_expected.to have_many(:folders) }
it { is_expected.to have_many(:articles) }
it { is_expected.to have_many(:inboxes) }
end
describe 'validations' do
let!(:account) { create(:account) }
let!(:portal) { create(:portal, account_id: account.id) }
context 'when set portal config' do
it 'Adds default allowed_locales en' do
expect(portal.config).to be_present
expect(portal.config['allowed_locales']).to eq(['en'])
expect(portal.config['default_locale']).to eq('en')
end
it 'Does not allow any other config than allowed_locales' do
portal.update(config: { 'some_other_key': 'test_value' })
expect(portal).not_to be_valid
expect(portal.errors.full_messages[0]).to eq('Cofig in portal on some_other_key is not supported.')
end
it 'converts empty string to nil' do
portal.update(custom_domain: '')
expect(portal.custom_domain).to be_nil
end
end
end
end

View File

@@ -0,0 +1,8 @@
require 'rails_helper'
RSpec.describe RelatedCategory do
describe 'associations' do
it { is_expected.to belong_to(:category) }
it { is_expected.to belong_to(:related_category) }
end
end

View File

@@ -0,0 +1,16 @@
require 'rails_helper'
RSpec.describe ReportingEvent do
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
it { is_expected.to validate_presence_of(:name) }
it { is_expected.to validate_presence_of(:value) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to belong_to(:inbox).optional }
it { is_expected.to belong_to(:user).optional }
it { is_expected.to belong_to(:conversation).optional }
end
end

View File

@@ -0,0 +1,8 @@
require 'rails_helper'
RSpec.describe TeamMember do
describe 'associations' do
it { is_expected.to belong_to(:team) }
it { is_expected.to belong_to(:user) }
end
end

View File

@@ -0,0 +1,56 @@
require 'rails_helper'
RSpec.describe Team do
describe 'associations' do
it { is_expected.to belong_to(:account) }
it { is_expected.to have_many(:conversations) }
it { is_expected.to have_many(:team_members) }
end
describe '#add_members' do
let(:team) { FactoryBot.create(:team) }
before do
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'handles adds all members and resets cache keys' do
users = FactoryBot.create_list(:user, 3)
team.add_members(users.map(&:id))
expect(team.reload.team_members.size).to eq(3)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
.with(
'account.cache_invalidated',
kind_of(Time),
account: team.account,
cache_keys: team.account.cache_keys
)
end
end
describe '#remove_members' do
let(:team) { FactoryBot.create(:team) }
let(:users) { FactoryBot.create_list(:user, 3) }
before do
team.add_members(users.map(&:id))
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
it 'removes the members and resets cache keys' do
expect(team.reload.team_members.size).to eq(3)
team.remove_members(users.map(&:id))
expect(team.reload.team_members.size).to eq(0)
expect(Rails.configuration.dispatcher).to have_received(:dispatch).at_least(:once)
.with(
'account.cache_invalidated',
kind_of(Time),
account: team.account,
cache_keys: team.account.cache_keys
)
end
end
end

View File

@@ -0,0 +1,257 @@
# frozen_string_literal: true
require 'rails_helper'
require Rails.root.join 'spec/models/concerns/access_tokenable_shared.rb'
require Rails.root.join 'spec/models/concerns/avatarable_shared.rb'
RSpec.describe User do
let!(:user) { create(:user) }
context 'with validations' do
it { is_expected.to validate_presence_of(:email) }
end
context 'with associations' do
it { is_expected.to have_many(:accounts).through(:account_users) }
it { is_expected.to have_many(:account_users) }
it { is_expected.to have_many(:assigned_conversations).dependent(:nullify) }
it { is_expected.to have_many(:inbox_members).dependent(:destroy_async) }
it { is_expected.to have_many(:notification_settings).dependent(:destroy_async) }
it { is_expected.to have_many(:messages) }
it { is_expected.to have_many(:reporting_events) }
it { is_expected.to have_many(:teams) }
end
describe 'concerns' do
it_behaves_like 'access_tokenable'
it_behaves_like 'avatarable'
end
describe 'pubsub_token' do
before { user.update(name: Faker::Name.name) }
it { expect(user.pubsub_token).not_to be_nil }
it { expect(user.saved_changes.keys).not_to eq('pubsub_token') }
context 'with rotate the pubsub_token' do
it 'changes the pubsub_token when password changes' do
pubsub_token = user.pubsub_token
user.password = Faker::Internet.password(special_characters: true)
user.save!
expect(user.pubsub_token).not_to eq(pubsub_token)
end
it 'will not change pubsub_token when other attributes change' do
pubsub_token = user.pubsub_token
user.name = Faker::Name.name
user.save!
expect(user.pubsub_token).to eq(pubsub_token)
end
end
end
describe 'hmac_identifier' do
it 'return nil if CHATWOOT_INBOX_HMAC_KEY is not set' do
expect(user.hmac_identifier).to eq('')
end
it 'return value if CHATWOOT_INBOX_HMAC_KEY is set' do
ConfigLoader.new.process
i = InstallationConfig.find_by(name: 'CHATWOOT_INBOX_HMAC_KEY')
i.value = 'random_secret_key'
i.save!
GlobalConfig.clear_cache
expected_hmac_identifier = OpenSSL::HMAC.hexdigest('sha256', 'random_secret_key', user.email)
expect(user.hmac_identifier).to eq expected_hmac_identifier
end
end
context 'with sso_auth_token' do
it 'can generate multiple sso tokens which can be validated' do
sso_auth_token1 = user.generate_sso_auth_token
sso_auth_token2 = user.generate_sso_auth_token
expect(sso_auth_token1).present?
expect(sso_auth_token2).present?
expect(user.valid_sso_auth_token?(sso_auth_token1)).to be true
expect(user.valid_sso_auth_token?(sso_auth_token2)).to be true
end
it 'wont validate an invalid token' do
expect(user.valid_sso_auth_token?(SecureRandom.hex(32))).to be false
end
it 'wont validate an invalidated token' do
sso_auth_token = user.generate_sso_auth_token
user.invalidate_sso_auth_token(sso_auth_token)
expect(user.valid_sso_auth_token?(sso_auth_token)).to be false
end
end
describe 'access token' do
it 'creates a single access token upon user creation' do
new_user = create(:user)
token_count = AccessToken.where(owner: new_user).count
expect(token_count).to eq(1)
end
end
context 'when user changes the email' do
it 'mutates the value' do
user.email = 'user@example.com'
expect(user.will_save_change_to_email?).to be true
end
end
context 'when the supplied email is uppercase' do
it 'downcases the email on save' do
new_user = create(:user, email: 'Test123@test.com')
expect(new_user.email).to eq('test123@test.com')
end
end
describe '2FA/MFA functionality' do
before do
skip('Skipping since MFA is not configured in this environment') unless Chatwoot.encryption_configured?
end
let(:user) { create(:user, password: 'Test@123456') }
describe '#enable_two_factor!' do
it 'generates OTP secret for 2FA setup' do
expect(user.otp_secret).to be_nil
expect(user.otp_required_for_login).to be_falsey
user.enable_two_factor!
expect(user.otp_secret).not_to be_nil
# otp_required_for_login is false until verification is complete
expect(user.otp_required_for_login).to be_falsey
end
end
describe '#disable_two_factor!' do
before do
user.enable_two_factor!
user.update!(otp_required_for_login: true) # Simulate verified 2FA
user.generate_backup_codes!
end
it 'disables 2FA and clears OTP secret' do
user.disable_two_factor!
expect(user.otp_secret).to be_nil
expect(user.otp_required_for_login).to be_falsey
expect(user.otp_backup_codes).to be_blank # Can be nil or empty array
end
end
describe '#generate_backup_codes!' do
before do
user.enable_two_factor!
end
it 'generates 10 backup codes' do
codes = user.generate_backup_codes!
expect(codes).to be_an(Array)
expect(codes.length).to eq(10)
expect(codes.first).to match(/\A[A-F0-9]{8}\z/) # 8-character hex codes
expect(user.otp_backup_codes).not_to be_nil
end
end
describe '#two_factor_provisioning_uri' do
before do
user.enable_two_factor!
end
it 'generates a valid provisioning URI for QR code' do
uri = user.two_factor_provisioning_uri
expect(uri).to include('otpauth://totp/')
expect(uri).to include(CGI.escape(user.email))
expect(uri).to include('Chatwoot')
end
end
describe '#validate_backup_code!' do
let(:backup_codes) { user.generate_backup_codes! }
before do
user.enable_two_factor!
backup_codes
end
it 'validates and invalidates correct backup code' do
code = backup_codes.first
result = user.validate_backup_code!(code)
expect(result).to be_truthy
# Verify it's marked as used
user.reload
expect(user.otp_backup_codes).to include('XXXXXXXX')
end
it 'rejects invalid backup code' do
result = user.validate_backup_code!('invalid')
expect(result).to be_falsey
end
it 'rejects already used backup code' do
code = backup_codes.first
user.validate_backup_code!(code)
# Try to use the same code again
result = user.validate_backup_code!(code)
expect(result).to be_falsey
end
it 'handles blank code' do
result = user.validate_backup_code!(nil)
expect(result).to be_falsey
result = user.validate_backup_code!('')
expect(result).to be_falsey
end
end
end
describe '#active_account_user' do
let(:user) { create(:user) }
let(:account1) { create(:account) }
let(:account2) { create(:account) }
let(:account3) { create(:account) }
before do
# Create account_users with different active_at values
create(:account_user, user: user, account: account1, active_at: 2.days.ago)
create(:account_user, user: user, account: account2, active_at: 1.day.ago)
create(:account_user, user: user, account: account3, active_at: nil) # New account with NULL active_at
end
it 'returns the account_user with the most recent active_at, prioritizing timestamps over NULL values' do
# Should return account2 (most recent timestamp) even though account3 was created last with NULL active_at
expect(user.active_account_user.account_id).to eq(account2.id)
end
it 'returns NULL active_at account only when no other accounts have active_at' do
# Remove active_at from all accounts
user.account_users.each { |au| au.update!(active_at: nil) }
# Should return one of the accounts (behavior is undefined but consistent)
expect(user.active_account_user).to be_present
end
context 'when multiple accounts have NULL active_at' do
before do
create(:account_user, user: user, account: create(:account), active_at: nil)
end
it 'still prioritizes accounts with timestamps' do
expect(user.active_account_user.account_id).to eq(account2.id)
end
end
end
end

View File

@@ -0,0 +1,11 @@
require 'rails_helper'
RSpec.describe Webhook do
describe 'validations' do
it { is_expected.to validate_presence_of(:account_id) }
end
describe 'associations' do
it { is_expected.to belong_to(:account) }
end
end

View File

@@ -0,0 +1,105 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe WorkingHour do
context 'when on monday 10am' do
before do
Time.zone = 'UTC'
create(:working_hour)
travel_to '26.10.2020 10:00'.to_datetime
end
it 'is considered working hour' do
expect(described_class.today.open_now?).to be true
end
end
context 'when on sunday 1pm' do
before do
Time.zone = 'UTC'
create(:working_hour, day_of_week: 0, closed_all_day: true)
travel_to '01.11.2020 13:00'.to_datetime
end
it 'is considered out of office' do
expect(described_class.today.closed_now?).to be true
end
end
context 'when on friday 12:30pm' do
before do
Time.zone = 'UTC'
create(:working_hour)
travel_to '10.09.2021 12:30'.to_datetime
end
it 'is considered to be in business hours' do
expect(described_class.today.open_now?).to be true
end
end
context 'when on friday 17:30pm' do
before do
Time.zone = 'UTC'
create(:working_hour)
travel_to '10.09.2021 17:30'.to_datetime
end
it 'is considered out of office' do
expect(described_class.today.closed_now?).to be true
end
end
context 'when open_all_day is true' do
let(:inbox) { create(:inbox) }
before do
Time.zone = 'UTC'
inbox.working_hours.find_by(day_of_week: 5).update(open_all_day: true)
travel_to '18.02.2022 11:00'.to_datetime
end
it 'updates open hour and close hour' do
expect(described_class.today.open_all_day?).to be true
expect(described_class.today.open_hour).to be 0
expect(described_class.today.open_minutes).to be 0
expect(described_class.today.close_hour).to be 23
expect(described_class.today.close_minutes).to be 59
end
end
context 'when open_all_day and closed_all_day true at the same time' do
let(:inbox) { create(:inbox) }
before do
Time.zone = 'UTC'
inbox.working_hours.find_by(day_of_week: 5).update(open_all_day: true)
travel_to '18.02.2022 11:00'.to_datetime
end
it 'throws validation error' do
working_hour = inbox.working_hours.find_by(day_of_week: 5)
working_hour.closed_all_day = true
expect(working_hour.invalid?).to be true
expect do
working_hour.save!
end.to raise_error(ActiveRecord::RecordInvalid,
'Validation failed: open_all_day and closed_all_day cannot be true at the same time')
end
end
context 'when on monday 9am in Sydney timezone' do
let(:inbox) { create(:inbox) }
before do
Time.zone = 'Australia/Sydney'
inbox.update(timezone: 'Australia/Sydney')
travel_to '10.10.2022 9:00 AEDT'
end
it 'is considered working hour' do
expect(described_class.today.open_now?).to be true
end
end
end