Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
0
research/chatwoot/spec/models/.keep
Normal file
0
research/chatwoot/spec/models/.keep
Normal file
276
research/chatwoot/spec/models/account_spec.rb
Normal file
276
research/chatwoot/spec/models/account_spec.rb
Normal 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
|
||||
45
research/chatwoot/spec/models/account_user_spec.rb
Normal file
45
research/chatwoot/spec/models/account_user_spec.rb
Normal 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
|
||||
13
research/chatwoot/spec/models/agent_bot_inbox_spec.rb
Normal file
13
research/chatwoot/spec/models/agent_bot_inbox_spec.rb
Normal 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
|
||||
61
research/chatwoot/spec/models/agent_bot_spec.rb
Normal file
61
research/chatwoot/spec/models/agent_bot_spec.rb
Normal 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
|
||||
@@ -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
|
||||
192
research/chatwoot/spec/models/article_spec.rb
Normal file
192
research/chatwoot/spec/models/article_spec.rb
Normal 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
|
||||
56
research/chatwoot/spec/models/assignment_policy_spec.rb
Normal file
56
research/chatwoot/spec/models/assignment_policy_spec.rb
Normal 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
|
||||
200
research/chatwoot/spec/models/attachment_spec.rb
Normal file
200
research/chatwoot/spec/models/attachment_spec.rb
Normal 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
|
||||
121
research/chatwoot/spec/models/automation_rule_spec.rb
Normal file
121
research/chatwoot/spec/models/automation_rule_spec.rb
Normal 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
|
||||
163
research/chatwoot/spec/models/campaign_spec.rb
Normal file
163
research/chatwoot/spec/models/campaign_spec.rb
Normal 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
|
||||
65
research/chatwoot/spec/models/category_spec.rb
Normal file
65
research/chatwoot/spec/models/category_spec.rb
Normal 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
|
||||
23
research/chatwoot/spec/models/channel/api_spec.rb
Normal file
23
research/chatwoot/spec/models/channel/api_spec.rb
Normal 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
|
||||
49
research/chatwoot/spec/models/channel/email_spec.rb
Normal file
49
research/chatwoot/spec/models/channel/email_spec.rb
Normal 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
|
||||
38
research/chatwoot/spec/models/channel/facebook_page_spec.rb
Normal file
38
research/chatwoot/spec/models/channel/facebook_page_spec.rb
Normal 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
|
||||
35
research/chatwoot/spec/models/channel/instagram_spec.rb
Normal file
35
research/chatwoot/spec/models/channel/instagram_spec.rb
Normal 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
|
||||
174
research/chatwoot/spec/models/channel/telegram_spec.rb
Normal file
174
research/chatwoot/spec/models/channel/telegram_spec.rb
Normal 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 <b>HTML</b> 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 <b>bold</b> and this is <i>italic</i>.\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 <span>test</span> 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: & < >')
|
||||
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
|
||||
85
research/chatwoot/spec/models/channel/twilio_sms_spec.rb
Normal file
85
research/chatwoot/spec/models/channel/twilio_sms_spec.rb
Normal 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
|
||||
15
research/chatwoot/spec/models/channel/web_widget_spec.rb
Normal file
15
research/chatwoot/spec/models/channel/web_widget_spec.rb
Normal 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
|
||||
212
research/chatwoot/spec/models/channel/whatsapp_spec.rb
Normal file
212
research/chatwoot/spec/models/channel/whatsapp_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
44
research/chatwoot/spec/models/concerns/avatarable_shared.rb
Normal file
44
research/chatwoot/spec/models/concerns/avatarable_shared.rb
Normal 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
|
||||
69
research/chatwoot/spec/models/concerns/cache_keys_spec.rb
Normal file
69
research/chatwoot/spec/models/concerns/cache_keys_spec.rb
Normal 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
|
||||
@@ -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
|
||||
@@ -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
|
||||
227
research/chatwoot/spec/models/concerns/liquidable_shared.rb
Normal file
227
research/chatwoot/spec/models/concerns/liquidable_shared.rb
Normal 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
|
||||
@@ -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
|
||||
103
research/chatwoot/spec/models/concerns/reauthorizable_shared.rb
Normal file
103
research/chatwoot/spec/models/concerns/reauthorizable_shared.rb
Normal 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
|
||||
95
research/chatwoot/spec/models/concerns/switch_locale_spec.rb
Normal file
95
research/chatwoot/spec/models/concerns/switch_locale_spec.rb
Normal 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
|
||||
107
research/chatwoot/spec/models/contact_inbox_spec.rb
Normal file
107
research/chatwoot/spec/models/contact_inbox_spec.rb
Normal 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
|
||||
199
research/chatwoot/spec/models/contact_spec.rb
Normal file
199
research/chatwoot/spec/models/contact_spec.rb
Normal 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
|
||||
@@ -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
|
||||
1087
research/chatwoot/spec/models/conversation_spec.rb
Normal file
1087
research/chatwoot/spec/models/conversation_spec.rb
Normal file
File diff suppressed because it is too large
Load Diff
28
research/chatwoot/spec/models/csat_survey_response_spec.rb
Normal file
28
research/chatwoot/spec/models/csat_survey_response_spec.rb
Normal 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
|
||||
23
research/chatwoot/spec/models/data_import_spec.rb
Normal file
23
research/chatwoot/spec/models/data_import_spec.rb
Normal 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
|
||||
@@ -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
|
||||
15
research/chatwoot/spec/models/folder_spec.rb
Normal file
15
research/chatwoot/spec/models/folder_spec.rb
Normal 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
|
||||
21
research/chatwoot/spec/models/inbox_member_spec.rb
Normal file
21
research/chatwoot/spec/models/inbox_member_spec.rb
Normal 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
|
||||
391
research/chatwoot/spec/models/inbox_spec.rb
Normal file
391
research/chatwoot/spec/models/inbox_spec.rb
Normal 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
|
||||
@@ -0,0 +1,7 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe InstallationConfig do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
end
|
||||
126
research/chatwoot/spec/models/integrations/app_spec.rb
Normal file
126
research/chatwoot/spec/models/integrations/app_spec.rb
Normal 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
|
||||
114
research/chatwoot/spec/models/integrations/hook_spec.rb
Normal file
114
research/chatwoot/spec/models/integrations/hook_spec.rb
Normal 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
|
||||
58
research/chatwoot/spec/models/label_spec.rb
Normal file
58
research/chatwoot/spec/models/label_spec.rb
Normal 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
|
||||
120
research/chatwoot/spec/models/macro_spec.rb
Normal file
120
research/chatwoot/spec/models/macro_spec.rb
Normal 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
|
||||
67
research/chatwoot/spec/models/mention_spec.rb
Normal file
67
research/chatwoot/spec/models/mention_spec.rb
Normal 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
|
||||
845
research/chatwoot/spec/models/message_spec.rb
Normal file
845
research/chatwoot/spec/models/message_spec.rb
Normal 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
|
||||
22
research/chatwoot/spec/models/note_spec.rb
Normal file
22
research/chatwoot/spec/models/note_spec.rb
Normal 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
|
||||
10
research/chatwoot/spec/models/notification_setting_spec.rb
Normal file
10
research/chatwoot/spec/models/notification_setting_spec.rb
Normal 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
|
||||
201
research/chatwoot/spec/models/notification_spec.rb
Normal file
201
research/chatwoot/spec/models/notification_spec.rb
Normal 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
|
||||
@@ -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
|
||||
24
research/chatwoot/spec/models/platform_app_spec.rb
Normal file
24
research/chatwoot/spec/models/platform_app_spec.rb
Normal 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
|
||||
41
research/chatwoot/spec/models/portal_spec.rb
Normal file
41
research/chatwoot/spec/models/portal_spec.rb
Normal 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
|
||||
8
research/chatwoot/spec/models/related_category_spec.rb
Normal file
8
research/chatwoot/spec/models/related_category_spec.rb
Normal 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
|
||||
16
research/chatwoot/spec/models/reporting_event_spec.rb
Normal file
16
research/chatwoot/spec/models/reporting_event_spec.rb
Normal 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
|
||||
8
research/chatwoot/spec/models/team_member_spec.rb
Normal file
8
research/chatwoot/spec/models/team_member_spec.rb
Normal 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
|
||||
56
research/chatwoot/spec/models/team_spec.rb
Normal file
56
research/chatwoot/spec/models/team_spec.rb
Normal 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
|
||||
257
research/chatwoot/spec/models/user_spec.rb
Normal file
257
research/chatwoot/spec/models/user_spec.rb
Normal 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
|
||||
11
research/chatwoot/spec/models/webhook_spec.rb
Normal file
11
research/chatwoot/spec/models/webhook_spec.rb
Normal 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
|
||||
105
research/chatwoot/spec/models/working_hour_spec.rb
Normal file
105
research/chatwoot/spec/models/working_hour_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user