Restructure omni services and add Chatwoot research snapshot
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user