Restructure omni services and add Chatwoot research snapshot

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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