When AgentBot responds to customer messages, the `waiting_since`
timestamp is not reset, causing inflated reply time metrics when a human
agent eventually responds. This results in inaccurate reporting that
incorrectly includes periods when customers were satisfied with bot
responses.
### Timeline from Production Data
```
Dec 12, 16:20:14 - Customer sends message (ID: 368451924)
↓ waiting_since = Dec 12, 16:20:14
Dec 12, 16:20:17 - AgentBot replies (ID: 368451960)
↓ waiting_since STILL = Dec 12, 16:20:14 ❌
↓ (Bot response doesn't clear it)
14-day gap - Customer satisfied, no messages
↓ waiting_since STILL = Dec 12, 16:20:14 ❌
Dec 26, 22:25:45 - Customer sends new message (ID: 383522275)
↓ waiting_since STILL = Dec 12, 16:20:14 ❌
↓ (New message doesn't reset it)
Dec 26-27 - More AgentBot interactions
↓ waiting_since STILL = Dec 12, 16:20:14 ❌
Dec 27, 07:36:53 - Human agent finally replies (ID: 383799517)
↓ Reply time calculated: 1,268,404 seconds
↓ = 14.7 DAYS ❌
```
## Root Cause
The core issues is in `app/models/message.rb`, where **AgentBot messages
does not clear `waiting_since`** - The `human_response?` method only
returns true for `User` senders, so bot replies never trigger the
clearing logic. This means once `waiting_since` is set, it stays set
even when customers send new messages after receiving bot responses.
The solution is to simply reset `waiting_since` **after a bot has
responded**. This ensures reply time metrics reflect actual human agent
response times, not bot-handled periods.
### What triggers the rest
This is an intentional "gotcha", that only `AgentBot` and
`Captain::Assistant` messages trigger the waiting time reset. Automation
and campaign messages maintain current behavior (no reset). This is
because interactive bot assistants provide conversational help that
might satisfy customers. Automation and campaigns are one-way
communications and shouldn't affect waiting time calculations.
## Related Work
Extends PR #11787 which fixed `waiting_since` clearing on conversation
resolution. This PR addresses the bot interaction scenario which was not
covered by that fix.
Scripts to clean data:
https://gist.github.com/scmmishra/bd133208e219d0ab52fbfdf03036c48a
1048 lines
42 KiB
Ruby
1048 lines
42 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require 'rails_helper'
|
|
require Rails.root.join 'spec/models/concerns/assignment_handler_shared.rb'
|
|
require Rails.root.join 'spec/models/concerns/auto_assignment_handler_shared.rb'
|
|
|
|
RSpec.describe Conversation do
|
|
after do
|
|
Current.user = nil
|
|
Current.account = nil
|
|
end
|
|
|
|
describe 'associations' do
|
|
it { is_expected.to belong_to(:account) }
|
|
it { is_expected.to belong_to(:inbox) }
|
|
it { is_expected.to belong_to(:contact) }
|
|
it { is_expected.to belong_to(:contact_inbox) }
|
|
it { is_expected.to belong_to(:assignee).optional }
|
|
it { is_expected.to belong_to(:team).optional }
|
|
it { is_expected.to belong_to(:campaign).optional }
|
|
end
|
|
|
|
describe 'concerns' do
|
|
it_behaves_like 'assignment_handler'
|
|
it_behaves_like 'auto_assignment_handler'
|
|
end
|
|
|
|
describe '.before_create' do
|
|
let(:conversation) { build(:conversation, display_id: nil) }
|
|
|
|
before do
|
|
conversation.save!
|
|
conversation.reload
|
|
end
|
|
|
|
it 'runs before_create callbacks' do
|
|
expect(conversation.display_id).to eq(1)
|
|
end
|
|
|
|
it 'sets waiting since' do
|
|
expect(conversation.waiting_since).not_to be_nil
|
|
end
|
|
|
|
it 'creates a UUID for every conversation automatically' do
|
|
uuid_pattern = /[0-9a-f]{8}\b-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-\b[0-9a-f]{12}$/i
|
|
expect(conversation.uuid).to match(uuid_pattern)
|
|
end
|
|
end
|
|
|
|
describe '.after_create' do
|
|
let(:account) { create(:account) }
|
|
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
|
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
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
|
end
|
|
|
|
it 'runs after_create callbacks' do
|
|
# send_events
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_CREATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
|
|
changed_attributes: nil, performed_by: nil)
|
|
end
|
|
end
|
|
|
|
describe '.validate jsonb attributes' do
|
|
let(:account) { create(:account) }
|
|
let(:agent) { create(:user, email: 'agent1@example.com', account: account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:conversation) do
|
|
create(
|
|
:conversation,
|
|
account: account,
|
|
contact: create(:contact, account: account),
|
|
inbox: inbox,
|
|
assignee: nil
|
|
)
|
|
end
|
|
|
|
it 'validate length of additional_attributes value' do
|
|
conversation.additional_attributes = { company_name: 'some_company' * 200, contact_number: 19_999_999_999 }
|
|
conversation.valid?
|
|
error_messages = conversation.errors.messages
|
|
expect(error_messages[:additional_attributes][0]).to eq('company_name length should be < 1500')
|
|
expect(error_messages[:additional_attributes][1]).to eq('contact_number value should be < 9999999999')
|
|
end
|
|
|
|
it 'validate length of custom_attributes value' do
|
|
conversation.custom_attributes = { company_name: 'some_company' * 200, contact_number: 19_999_999_999 }
|
|
conversation.valid?
|
|
error_messages = conversation.errors.messages
|
|
expect(error_messages[:custom_attributes][0]).to eq('company_name length should be < 1500')
|
|
expect(error_messages[:custom_attributes][1]).to eq('contact_number value should be < 9999999999')
|
|
end
|
|
end
|
|
|
|
describe '.after_update' do
|
|
let!(:account) { create(:account) }
|
|
let!(:old_assignee) do
|
|
create(:user, email: 'agent1@example.com', account: account, role: :agent)
|
|
end
|
|
let(:new_assignee) do
|
|
create(:user, email: 'agent2@example.com', account: account, role: :agent)
|
|
end
|
|
let!(:conversation) do
|
|
create(:conversation, status: 'open', account: account, assignee: old_assignee)
|
|
end
|
|
let(:assignment_mailer) { instance_double(AssignmentMailer, deliver: true) }
|
|
let(:label) { create(:label, account: account) }
|
|
|
|
before do
|
|
create(:inbox_member, user: old_assignee, inbox: conversation.inbox)
|
|
create(:inbox_member, user: new_assignee, inbox: conversation.inbox)
|
|
allow(Rails.configuration.dispatcher).to receive(:dispatch)
|
|
Current.user = old_assignee
|
|
end
|
|
|
|
it 'sends conversation updated event if labels are updated' do
|
|
conversation.update(label_list: [label.title])
|
|
changed_attributes = conversation.previous_changes
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(
|
|
described_class::CONVERSATION_UPDATED,
|
|
kind_of(Time),
|
|
conversation: conversation,
|
|
notifiable_assignee_change: false,
|
|
changed_attributes: changed_attributes,
|
|
performed_by: nil
|
|
)
|
|
end
|
|
|
|
it 'runs after_update callbacks' do
|
|
conversation.update(
|
|
status: :resolved,
|
|
contact_last_seen_at: Time.zone.now,
|
|
assignee: new_assignee
|
|
)
|
|
status_change = conversation.status_change
|
|
changed_attributes = conversation.previous_changes
|
|
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_RESOLVED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
|
|
changed_attributes: status_change, performed_by: nil)
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_READ, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
|
|
changed_attributes: nil, performed_by: nil)
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::ASSIGNEE_CHANGED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
|
|
changed_attributes: changed_attributes, performed_by: nil)
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true,
|
|
changed_attributes: changed_attributes, performed_by: nil)
|
|
end
|
|
|
|
it 'will not run conversation_updated event for empty updates' do
|
|
conversation.save!
|
|
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
|
|
end
|
|
|
|
it 'will not run conversation_updated event for non whitelisted keys' do
|
|
conversation.update(updated_at: DateTime.now.utc)
|
|
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
|
|
end
|
|
|
|
it 'will run conversation_updated event for conversation_language in additional_attributes' do
|
|
conversation.additional_attributes[:conversation_language] = 'es'
|
|
conversation.save!
|
|
changed_attributes = conversation.previous_changes
|
|
expect(Rails.configuration.dispatcher).to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: false,
|
|
changed_attributes: changed_attributes, performed_by: nil)
|
|
end
|
|
|
|
it 'will not run conversation_updated event for bowser_language in additional_attributes' do
|
|
conversation.additional_attributes[:browser_language] = 'es'
|
|
conversation.save!
|
|
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
|
|
.with(described_class::CONVERSATION_UPDATED, kind_of(Time), conversation: conversation, notifiable_assignee_change: true)
|
|
end
|
|
|
|
it 'creates conversation activities' do
|
|
conversation.update(
|
|
status: :resolved,
|
|
contact_last_seen_at: Time.zone.now,
|
|
assignee: new_assignee,
|
|
label_list: [label.title]
|
|
)
|
|
|
|
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: "#{old_assignee.name} added #{label.title}" }))
|
|
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: "Conversation was marked resolved by #{old_assignee.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: "Assigned to #{new_assignee.name} by #{old_assignee.name}" }))
|
|
end
|
|
|
|
it 'adds a message for system auto resolution if marked resolved by system' do
|
|
account.update(auto_resolve_after: 40 * 24 * 60)
|
|
conversation2 = create(:conversation, status: 'open', account: account, assignee: old_assignee)
|
|
Current.reset
|
|
|
|
message_data = if account.auto_resolve_after >= 1440 && account.auto_resolve_after % 1440 == 0
|
|
{ key: 'auto_resolved_days', count: account.auto_resolve_after / 1440 }
|
|
elsif account.auto_resolve_after >= 60 && account.auto_resolve_after % 60 == 0
|
|
{ key: 'auto_resolved_hours', count: account.auto_resolve_after / 60 }
|
|
else
|
|
{ key: 'auto_resolved_minutes', count: account.auto_resolve_after }
|
|
end
|
|
system_resolved_message = "Conversation was marked resolved by system due to #{message_data[:count]} days of inactivity"
|
|
expect { conversation2.update(status: :resolved) }
|
|
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
|
.with(conversation2, { account_id: conversation2.account_id, inbox_id: conversation2.inbox_id, message_type: :activity,
|
|
content: system_resolved_message })
|
|
end
|
|
end
|
|
|
|
describe '#update_labels' do
|
|
let(:account) { create(:account) }
|
|
let(:conversation) { create(:conversation, account: account) }
|
|
let(:agent) do
|
|
create(:user, email: 'agent@example.com', account: account, role: :agent)
|
|
end
|
|
let(:first_label) { create(:label, account: account) }
|
|
let(:second_label) { create(:label, account: account) }
|
|
let(:third_label) { create(:label, account: account) }
|
|
let(:fourth_label) { create(:label, account: account) }
|
|
|
|
before do
|
|
conversation
|
|
Current.user = agent
|
|
|
|
first_label
|
|
second_label
|
|
third_label
|
|
fourth_label
|
|
end
|
|
|
|
it 'adds one label to conversation' do
|
|
labels = [first_label].map(&:title)
|
|
|
|
expect { conversation.update_labels(labels) }
|
|
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
|
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
|
content: "#{agent.name} added #{labels.join(', ')}" })
|
|
|
|
expect(conversation.label_list).to match_array(labels)
|
|
end
|
|
|
|
it 'adds and removes previously added labels' do
|
|
labels = [first_label, fourth_label].map(&:title)
|
|
expect { conversation.update_labels(labels) }
|
|
.to have_enqueued_job(Conversations::ActivityMessageJob)
|
|
.with(conversation, { account_id: conversation.account_id, inbox_id: conversation.inbox_id, message_type: :activity,
|
|
content: "#{agent.name} added #{labels.join(', ')}" })
|
|
expect(conversation.label_list).to match_array(labels)
|
|
|
|
updated_labels = [second_label, third_label].map(&:title)
|
|
expect(conversation.update_labels(updated_labels)).to be(true)
|
|
expect(conversation.label_list).to match_array(updated_labels)
|
|
|
|
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: "#{agent.name} added #{updated_labels.join(', ')}" }))
|
|
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: "#{agent.name} removed #{labels.join(', ')}" }))
|
|
end
|
|
end
|
|
|
|
describe '#toggle_status' do
|
|
it 'toggles conversation status to resolved when open' do
|
|
conversation = create(:conversation, status: 'open')
|
|
expect(conversation.toggle_status).to be(true)
|
|
expect(conversation.reload.status).to eq('resolved')
|
|
end
|
|
|
|
it 'toggles conversation status to open when resolved' do
|
|
conversation = create(:conversation, status: 'resolved')
|
|
expect(conversation.toggle_status).to be(true)
|
|
expect(conversation.reload.status).to eq('open')
|
|
end
|
|
|
|
it 'toggles conversation status to open when pending' do
|
|
conversation = create(:conversation, status: 'pending')
|
|
expect(conversation.toggle_status).to be(true)
|
|
expect(conversation.reload.status).to eq('open')
|
|
end
|
|
|
|
it 'toggles conversation status to open when snoozed' do
|
|
conversation = create(:conversation, status: 'snoozed')
|
|
expect(conversation.toggle_status).to be(true)
|
|
expect(conversation.reload.status).to eq('open')
|
|
end
|
|
end
|
|
|
|
describe '#toggle_priority' do
|
|
it 'defaults priority to nil when created' do
|
|
conversation = create(:conversation, status: 'open')
|
|
expect(conversation.priority).to be_nil
|
|
end
|
|
|
|
it 'toggles the priority to nil if nothing is passed' do
|
|
conversation = create(:conversation, status: 'open', priority: 'high')
|
|
expect(conversation.toggle_priority).to be(true)
|
|
expect(conversation.reload.priority).to be_nil
|
|
end
|
|
|
|
it 'sets the priority to low' do
|
|
conversation = create(:conversation, status: 'open')
|
|
|
|
expect(conversation.toggle_priority('low')).to be(true)
|
|
expect(conversation.reload.priority).to eq('low')
|
|
end
|
|
|
|
it 'sets the priority to medium' do
|
|
conversation = create(:conversation, status: 'open')
|
|
|
|
expect(conversation.toggle_priority('medium')).to be(true)
|
|
expect(conversation.reload.priority).to eq('medium')
|
|
end
|
|
|
|
it 'sets the priority to high' do
|
|
conversation = create(:conversation, status: 'open')
|
|
|
|
expect(conversation.toggle_priority('high')).to be(true)
|
|
expect(conversation.reload.priority).to eq('high')
|
|
end
|
|
|
|
it 'sets the priority to urgent' do
|
|
conversation = create(:conversation, status: 'open')
|
|
|
|
expect(conversation.toggle_priority('urgent')).to be(true)
|
|
expect(conversation.reload.priority).to eq('urgent')
|
|
end
|
|
end
|
|
|
|
describe '#ensure_snooze_until_reset' do
|
|
it 'resets the snoozed_until when status is toggled' do
|
|
conversation = create(:conversation, status: 'snoozed', snoozed_until: 2.days.from_now)
|
|
expect(conversation.snoozed_until).not_to be_nil
|
|
expect(conversation.toggle_status).to be(true)
|
|
expect(conversation.reload.snoozed_until).to be_nil
|
|
end
|
|
end
|
|
|
|
describe '#mute!' do
|
|
subject(:mute!) { conversation.mute! }
|
|
|
|
let(:user) do
|
|
create(:user, email: 'agent2@example.com', account: create(:account), role: :agent)
|
|
end
|
|
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
before { Current.user = user }
|
|
|
|
it 'marks conversation as resolved' do
|
|
mute!
|
|
expect(conversation.reload.resolved?).to be(true)
|
|
end
|
|
|
|
it 'blocks the contact' do
|
|
mute!
|
|
expect(conversation.reload.contact.blocked?).to be(true)
|
|
end
|
|
|
|
it 'creates mute message' do
|
|
mute!
|
|
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: "#{user.name} has muted the conversation" }))
|
|
end
|
|
end
|
|
|
|
describe '#unmute!' do
|
|
subject(:unmute!) { conversation.unmute! }
|
|
|
|
let(:user) do
|
|
create(:user, email: 'agent2@example.com', account: create(:account), role: :agent)
|
|
end
|
|
|
|
let(:conversation) { create(:conversation).tap(&:mute!) }
|
|
|
|
before { Current.user = user }
|
|
|
|
it 'does not change conversation status' do
|
|
expect { unmute! }.not_to(change { conversation.reload.status })
|
|
end
|
|
|
|
it 'unblocks the contact' do
|
|
unmute!
|
|
expect(conversation.reload.contact.blocked?).to be(false)
|
|
end
|
|
|
|
it 'creates unmute message' do
|
|
unmute!
|
|
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: "#{user.name} has unmuted the conversation" }))
|
|
end
|
|
end
|
|
|
|
describe '#muted?' do
|
|
subject(:muted?) { conversation.muted? }
|
|
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
it 'return true if conversation is muted' do
|
|
conversation.mute!
|
|
expect(muted?).to be(true)
|
|
end
|
|
|
|
it 'returns false if conversation is not muted' do
|
|
expect(muted?).to be(false)
|
|
end
|
|
end
|
|
|
|
describe 'unread_messages' do
|
|
subject(:unread_messages) { conversation.unread_messages }
|
|
|
|
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
|
|
let(:message_params) do
|
|
{
|
|
conversation: conversation,
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
sender: conversation.assignee
|
|
}
|
|
end
|
|
let!(:message) do
|
|
create(:message, created_at: 1.minute.ago, **message_params)
|
|
end
|
|
|
|
before do
|
|
create(:message, created_at: 1.month.ago, **message_params)
|
|
end
|
|
|
|
it 'returns unread messages' do
|
|
expect(unread_messages).to include(message)
|
|
end
|
|
end
|
|
|
|
describe 'recent_messages' do
|
|
subject(:recent_messages) { conversation.recent_messages }
|
|
|
|
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
|
|
let(:message_params) do
|
|
{
|
|
conversation: conversation,
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
sender: conversation.assignee
|
|
}
|
|
end
|
|
let!(:messages) do
|
|
create_list(:message, 10, **message_params) do |message, i|
|
|
message.created_at = i.minute.ago
|
|
end
|
|
end
|
|
|
|
it 'returns upto 5 recent messages' do
|
|
expect(recent_messages.length).to be < 6
|
|
expect(recent_messages).to eq messages.last(5)
|
|
end
|
|
end
|
|
|
|
describe 'unread_incoming_messages' do
|
|
subject(:unread_incoming_messages) { conversation.unread_incoming_messages }
|
|
|
|
let(:conversation) { create(:conversation, agent_last_seen_at: 1.hour.ago) }
|
|
let(:message_params) do
|
|
{
|
|
conversation: conversation,
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
sender: conversation.assignee,
|
|
created_at: 1.minute.ago
|
|
}
|
|
end
|
|
let!(:message) do
|
|
create(:message, message_type: :incoming, **message_params)
|
|
end
|
|
|
|
before do
|
|
create(:message, message_type: :outgoing, **message_params)
|
|
end
|
|
|
|
it 'returns unread incoming messages' do
|
|
expect(unread_incoming_messages).to contain_exactly(message)
|
|
end
|
|
|
|
it 'returns unread incoming messages even if the agent has not seen the conversation' do
|
|
conversation.update!(agent_last_seen_at: nil)
|
|
|
|
expect(unread_incoming_messages).to contain_exactly(message)
|
|
end
|
|
end
|
|
|
|
describe '#push_event_data' do
|
|
subject(:push_event_data) { conversation.push_event_data }
|
|
|
|
let(:conversation) { create(:conversation) }
|
|
let(:expected_data) do
|
|
{
|
|
additional_attributes: {},
|
|
meta: {
|
|
sender: conversation.contact.push_event_data,
|
|
assignee: conversation.assigned_entity&.push_event_data,
|
|
assignee_type: conversation.assignee_type,
|
|
team: conversation.team&.push_event_data,
|
|
hmac_verified: conversation.contact_inbox.hmac_verified
|
|
},
|
|
id: conversation.display_id,
|
|
messages: [],
|
|
labels: [],
|
|
last_activity_at: conversation.last_activity_at.to_i,
|
|
inbox_id: conversation.inbox_id,
|
|
status: conversation.status,
|
|
contact_inbox: conversation.contact_inbox,
|
|
timestamp: conversation.last_activity_at.to_i,
|
|
can_reply: true,
|
|
channel: 'Channel::WebWidget',
|
|
snoozed_until: conversation.snoozed_until,
|
|
custom_attributes: conversation.custom_attributes,
|
|
first_reply_created_at: nil,
|
|
contact_last_seen_at: conversation.contact_last_seen_at.to_i,
|
|
agent_last_seen_at: conversation.agent_last_seen_at.to_i,
|
|
created_at: conversation.created_at.to_i,
|
|
updated_at: conversation.updated_at.to_f,
|
|
waiting_since: conversation.waiting_since.to_i,
|
|
priority: nil,
|
|
unread_count: 0
|
|
}
|
|
end
|
|
|
|
it 'returns push event payload' do
|
|
expect(push_event_data).to eq(expected_data)
|
|
end
|
|
end
|
|
|
|
describe 'when conversation is created by blocked contact' do
|
|
let(:account) { create(:account) }
|
|
let(:blocked_contact) { create(:contact, account: account, blocked: true) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
|
|
it 'creates conversation in resolved state' do
|
|
conversation = create(:conversation, account: account, contact: blocked_contact, inbox: inbox)
|
|
expect(conversation.status).to eq('resolved')
|
|
end
|
|
end
|
|
|
|
describe '#botinbox: when conversation created inside inbox with agent bot' do
|
|
let!(:bot_inbox) { create(:agent_bot_inbox) }
|
|
let(:conversation) { create(:conversation, inbox: bot_inbox.inbox) }
|
|
|
|
it 'returns conversation status as pending' do
|
|
expect(conversation.status).to eq('pending')
|
|
end
|
|
|
|
context 'with campaigns' do
|
|
let(:user) { create(:user, account: bot_inbox.inbox.account) }
|
|
|
|
it 'returns conversation as open if campaign has a sender' do
|
|
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
|
|
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
|
expect(conversation.status).to eq('open')
|
|
end
|
|
|
|
it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
|
|
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
|
|
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
|
expect(conversation.status).to eq('pending')
|
|
end
|
|
end
|
|
|
|
context 'with campaigns in inbox without bot' do
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:user) { create(:user, account: account) }
|
|
|
|
it 'returns conversation as open if campaign has no sender but no bot is active' do
|
|
campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
|
|
conversation = create(:conversation, inbox: inbox, campaign: campaign)
|
|
expect(conversation.status).to eq('open')
|
|
end
|
|
|
|
it 'returns conversation as open if campaign has a sender' do
|
|
campaign = create(:campaign, inbox: inbox, account: account, sender: user)
|
|
conversation = create(:conversation, inbox: inbox, campaign: campaign)
|
|
expect(conversation.status).to eq('open')
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#botintegration: when conversation created in inbox with dialogflow integration' do
|
|
let(:inbox) { create(:inbox) }
|
|
let(:hook) { create(:integrations_hook, :dialogflow, inbox: inbox) }
|
|
let(:conversation) { create(:conversation, inbox: hook.inbox) }
|
|
|
|
it 'returns conversation status as pending' do
|
|
expect(conversation.status).to eq('pending')
|
|
end
|
|
end
|
|
|
|
describe '#delete conversation' do
|
|
include ActiveJob::TestHelper
|
|
|
|
let!(:conversation) { create(:conversation) }
|
|
|
|
let!(:notification) { create(:notification, notification_type: 'conversation_creation', primary_actor: conversation) }
|
|
|
|
it 'delete associated notifications if conversation is deleted' do
|
|
perform_enqueued_jobs do
|
|
conversation.destroy!
|
|
end
|
|
|
|
expect { notification.reload }.to raise_error ActiveRecord::RecordNotFound
|
|
end
|
|
end
|
|
|
|
describe 'validate invalid referer url' do
|
|
let(:conversation) { create(:conversation, additional_attributes: { referer: 'javascript' }) }
|
|
|
|
it 'returns nil' do
|
|
expect(conversation['additional_attributes']['referer']).to be_nil
|
|
end
|
|
end
|
|
|
|
describe 'validate valid referer url' do
|
|
let(:conversation) { create(:conversation, additional_attributes: { referer: 'https://www.chatwoot.com/' }) }
|
|
|
|
it 'returns nil' do
|
|
expect(conversation['additional_attributes']['referer']).to eq('https://www.chatwoot.com/')
|
|
end
|
|
end
|
|
|
|
describe 'custom sort option' do
|
|
include ActiveJob::TestHelper
|
|
|
|
let!(:conversation_7) { create(:conversation, created_at: DateTime.now - 6.days, last_activity_at: DateTime.now - 13.days) }
|
|
let!(:conversation_6) { create(:conversation, created_at: DateTime.now - 7.days, last_activity_at: DateTime.now - 10.days) }
|
|
let!(:conversation_5) { create(:conversation, created_at: DateTime.now - 8.days, last_activity_at: DateTime.now - 12.days, priority: :urgent) }
|
|
let!(:conversation_4) { create(:conversation, created_at: DateTime.now - 9.days, last_activity_at: DateTime.now - 11.days, priority: :urgent) }
|
|
let!(:conversation_3) { create(:conversation, created_at: DateTime.now - 5.days, last_activity_at: DateTime.now - 9.days, priority: :low) }
|
|
let!(:conversation_2) { create(:conversation, created_at: DateTime.now - 3.days, last_activity_at: DateTime.now - 6.days, priority: :high) }
|
|
let!(:conversation_1) { create(:conversation, created_at: DateTime.now - 4.days, last_activity_at: DateTime.now - 8.days, priority: :medium) }
|
|
|
|
describe 'sort_on_created_at' do
|
|
let(:created_desc_order) do
|
|
[
|
|
conversation_2.id, conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id,
|
|
conversation_5.id, conversation_4.id
|
|
]
|
|
end
|
|
|
|
it 'returns the list in ascending order by default' do
|
|
records = described_class.sort_on_created_at
|
|
expect(records.map(&:id)).to eq created_desc_order.reverse
|
|
end
|
|
|
|
it 'returns the list in descending order if desc is passed as sort direction' do
|
|
records = described_class.sort_on_created_at(:desc)
|
|
expect(records.map(&:id)).to eq created_desc_order
|
|
end
|
|
end
|
|
|
|
describe 'sort_on_last_activity_at' do
|
|
let(:last_activity_asc_order) do
|
|
[
|
|
conversation_7.id, conversation_5.id, conversation_4.id, conversation_6.id, conversation_3.id,
|
|
conversation_1.id, conversation_2.id
|
|
]
|
|
end
|
|
|
|
it 'returns the list in descending order by default' do
|
|
records = described_class.sort_on_last_activity_at
|
|
expect(records.map(&:id)).to eq last_activity_asc_order.reverse
|
|
end
|
|
|
|
it 'returns the list in asc order if asc is passed as sort direction' do
|
|
records = described_class.sort_on_last_activity_at(:asc)
|
|
expect(records.map(&:id)).to eq last_activity_asc_order
|
|
end
|
|
end
|
|
|
|
context 'when last_activity_at updated by some actions' do
|
|
before do
|
|
create(:message, conversation_id: conversation_1.id, message_type: :incoming, created_at: DateTime.now - 8.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 - 2.days)
|
|
end
|
|
|
|
it 'sort conversations with latest resolved conversation at first' do
|
|
records = described_class.sort_on_last_activity_at
|
|
|
|
expect(records.first.id).to eq(conversation_3.id)
|
|
|
|
conversation_1.toggle_status
|
|
perform_enqueued_jobs do
|
|
Conversations::ActivityMessageJob.perform_later(
|
|
conversation_1,
|
|
account_id: conversation_1.account_id,
|
|
inbox_id: conversation_1.inbox_id,
|
|
message_type: :activity,
|
|
content: 'Conversation was marked resolved by system due to days of inactivity'
|
|
)
|
|
end
|
|
records = described_class.sort_on_last_activity_at
|
|
|
|
expect(records.first.id).to eq(conversation_1.id)
|
|
end
|
|
|
|
it 'Sort conversations with latest message' do
|
|
create(:message, conversation_id: conversation_3.id, message_type: :incoming, created_at: DateTime.now)
|
|
records = described_class.sort_on_last_activity_at
|
|
|
|
expect(records.first.id).to eq(conversation_3.id)
|
|
end
|
|
end
|
|
|
|
describe 'sort_on_priority' do
|
|
it 'return list with the following order urgent > high > medium > low > nil by default' do
|
|
# ensure they are not pre-sorted
|
|
records = described_class.sort_on_created_at
|
|
expect(records.pluck(:priority)).not_to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
|
|
|
|
records = described_class.sort_on_priority
|
|
expect(records.pluck(:priority)).to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
|
|
expect(records.pluck(:id)).to eq(
|
|
[
|
|
conversation_4.id, conversation_5.id, conversation_2.id, conversation_1.id, conversation_3.id,
|
|
conversation_6.id, conversation_7.id
|
|
]
|
|
)
|
|
end
|
|
|
|
it 'return list with the following order low > medium > high > urgent > nil by default' do
|
|
# ensure they are not pre-sorted
|
|
records = described_class.sort_on_created_at
|
|
expect(records.pluck(:priority)).not_to eq(['urgent', 'urgent', 'high', 'medium', 'low', nil, nil])
|
|
|
|
records = described_class.sort_on_priority(:asc)
|
|
expect(records.pluck(:priority)).to eq(['low', 'medium', 'high', 'urgent', 'urgent', nil, nil])
|
|
expect(records.pluck(:id)).to eq(
|
|
[
|
|
conversation_3.id, conversation_1.id, conversation_2.id, conversation_4.id, conversation_5.id,
|
|
conversation_6.id, conversation_7.id
|
|
]
|
|
)
|
|
end
|
|
|
|
it 'sorts conversation with last_activity for the same priority' do
|
|
records = described_class.where(priority: 'urgent').sort_on_priority
|
|
# ensure that the conversation 4 last_activity_at is more recent than conversation 5
|
|
expect(conversation_4.last_activity_at > conversation_5.last_activity_at).to be(true)
|
|
expect(records.pluck(:priority, :id)).to eq([['urgent', conversation_4.id], ['urgent', conversation_5.id]])
|
|
|
|
records = described_class.where(priority: nil).sort_on_priority
|
|
# ensure that the conversation 6 last_activity_at is more recent than conversation 7
|
|
expect(conversation_6.last_activity_at > conversation_7.last_activity_at).to be(true)
|
|
expect(records.pluck(:priority, :id)).to eq([[nil, conversation_6.id], [nil, conversation_7.id]])
|
|
end
|
|
end
|
|
|
|
describe 'sort_on_waiting_since' do
|
|
it 'returns the list in ascending order by default' do
|
|
records = described_class.sort_on_waiting_since
|
|
expect(records.map(&:id)).to eq [
|
|
conversation_4.id, conversation_5.id, conversation_6.id, conversation_7.id, conversation_3.id, conversation_1.id,
|
|
conversation_2.id
|
|
]
|
|
end
|
|
|
|
it 'returns the list in desc order if asc is passed as sort direction' do
|
|
records = described_class.sort_on_waiting_since(:desc)
|
|
expect(records.map(&:id)).to eq [
|
|
conversation_2.id, conversation_1.id, conversation_3.id, conversation_7.id, conversation_6.id, conversation_5.id,
|
|
conversation_4.id
|
|
]
|
|
end
|
|
end
|
|
end
|
|
|
|
describe 'cached_label_list_array' do
|
|
let(:conversation) { create(:conversation) }
|
|
|
|
it 'returns the correct list of labels' do
|
|
conversation.update(label_list: %w[customer-support enterprise paid-customer])
|
|
|
|
expect(conversation.cached_label_list_array).to eq %w[customer-support enterprise paid-customer]
|
|
end
|
|
end
|
|
|
|
describe '#last_activity_at' do
|
|
let(:conversation) { create(:conversation) }
|
|
let(:message_params) do
|
|
{
|
|
conversation: conversation,
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
sender: conversation.assignee
|
|
}
|
|
end
|
|
|
|
context 'when a new conversation is created' do
|
|
it 'sets last_activity_at to the created_at time (within DB precision)' do
|
|
expect(conversation.last_activity_at).to be_within(1.second).of(conversation.created_at)
|
|
end
|
|
end
|
|
|
|
context 'when a new message is added' do
|
|
it 'updates the last_activity_at to the new message\'s created_at time' do
|
|
message = create(:message, created_at: 1.hour.from_now, **message_params)
|
|
conversation.reload
|
|
expect(conversation.last_activity_at).to be_within(1.second).of(message.created_at)
|
|
end
|
|
end
|
|
|
|
context 'when multiple messages are added' do
|
|
it 'sets last_activity_at to the most recent message\'s created_at time' do
|
|
create(:message, created_at: 2.hours.ago, **message_params)
|
|
latest_message = create(:message, created_at: 1.hour.from_now, **message_params)
|
|
conversation.reload
|
|
expect(conversation.last_activity_at).to be_within(1.second).of(latest_message.created_at)
|
|
end
|
|
end
|
|
end
|
|
|
|
describe '#can_reply?' do
|
|
let(:conversation) { create(:conversation) }
|
|
let(:message_window_service) { instance_double(Conversations::MessageWindowService) }
|
|
|
|
before do
|
|
allow(Conversations::MessageWindowService).to receive(:new).with(conversation).and_return(message_window_service)
|
|
end
|
|
|
|
it 'delegates to MessageWindowService' do
|
|
allow(message_window_service).to receive(:can_reply?).and_return(true)
|
|
expect(conversation.can_reply?).to be true
|
|
expect(message_window_service).to have_received(:can_reply?)
|
|
end
|
|
|
|
it 'returns false when MessageWindowService returns false' do
|
|
allow(message_window_service).to receive(:can_reply?).and_return(false)
|
|
expect(conversation.can_reply?).to be false
|
|
expect(message_window_service).to have_received(:can_reply?)
|
|
end
|
|
end
|
|
|
|
describe 'reply time calculation flows' do
|
|
include ActiveJob::TestHelper
|
|
|
|
let(:account) { create(:account) }
|
|
let(:inbox) { create(:inbox, account: account) }
|
|
let(:contact) { create(:contact, account: account) }
|
|
let(:agent) { create(:user, account: account, role: :agent) }
|
|
let(:conversation) { create(:conversation, account: account, inbox: inbox, contact: contact, assignee: agent, waiting_since: nil) }
|
|
let(:conversation_start_time) { 5.hours.ago }
|
|
|
|
before do
|
|
create(:inbox_member, user: agent, inbox: inbox)
|
|
# rubocop:disable Rails/SkipsModelValidations
|
|
conversation.update_column(:waiting_since, nil)
|
|
conversation.update_column(:created_at, conversation_start_time)
|
|
# rubocop:enable Rails/SkipsModelValidations
|
|
conversation.messages.destroy_all
|
|
conversation.reporting_events.destroy_all
|
|
conversation.reload
|
|
end
|
|
|
|
def create_customer_message(conversation, created_at: Time.current)
|
|
message = nil
|
|
perform_enqueued_jobs do
|
|
message = create(:message,
|
|
message_type: 'incoming',
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.contact,
|
|
created_at: created_at)
|
|
end
|
|
message
|
|
end
|
|
|
|
def create_agent_message(conversation, created_at: Time.current)
|
|
message = nil
|
|
perform_enqueued_jobs do
|
|
message = create(:message,
|
|
message_type: 'outgoing',
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: conversation.assignee,
|
|
created_at: created_at)
|
|
end
|
|
message
|
|
end
|
|
|
|
it 'correctly tracks waiting_since and creates first response time events' do
|
|
create_customer_message(conversation, created_at: conversation_start_time)
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
|
|
|
# Agent replies - this should create first response event
|
|
agent_reply1_time = 4.hours.ago
|
|
create_agent_message(conversation, created_at: agent_reply1_time)
|
|
|
|
first_response_events = account.reporting_events.where(name: 'first_response', conversation_id: conversation.id)
|
|
expect(first_response_events.count).to eq(1)
|
|
expect(first_response_events.first.value).to be_within(1.second).of(1.hour)
|
|
|
|
# the first response should also clear the waiting_since
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_nil
|
|
end
|
|
|
|
it 'does not reset waiting_since if customer sends another message' do
|
|
create_customer_message(conversation, created_at: conversation_start_time)
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
|
|
|
create_customer_message(conversation, created_at: 3.hours.ago)
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_within(1.second).of(conversation_start_time)
|
|
end
|
|
|
|
it 'records the correct reply_time for subsequent messages' do
|
|
create_customer_message(conversation, created_at: conversation_start_time)
|
|
create_agent_message(conversation, created_at: 4.hours.ago)
|
|
create_customer_message(conversation, created_at: 3.hours.ago)
|
|
|
|
create_agent_message(conversation, created_at: 2.hours.ago)
|
|
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
|
expect(reply_events.count).to eq(1)
|
|
expect(reply_events.first.value).to be_within(1.second).of(1.hour)
|
|
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_nil
|
|
end
|
|
|
|
it 'records zero reply time if an agent sends a message after resolution' do
|
|
create_customer_message(conversation, created_at: conversation_start_time)
|
|
create_agent_message(conversation, created_at: 4.hours.ago)
|
|
create_customer_message(conversation, created_at: 3.hours.ago)
|
|
|
|
conversation.toggle_status
|
|
expect(conversation.status).to eq('resolved')
|
|
|
|
conversation.toggle_status
|
|
expect(conversation.status).to eq('open')
|
|
|
|
conversation.reload
|
|
expect(conversation.waiting_since).to be_nil
|
|
|
|
create_agent_message(conversation, created_at: 1.hour.ago)
|
|
# update_waiting_since will ensure that no events were created since the waiting_since was nil
|
|
# if the event is created it should log zero value, we have handled that in the reporting_event_listener
|
|
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
|
expect(reply_events.count).to eq(0)
|
|
end
|
|
|
|
context 'when AgentBot responds between customer messages' do
|
|
let(:agent_bot) { create(:agent_bot, account: account) }
|
|
|
|
def create_bot_message(conversation, created_at: Time.current)
|
|
message = nil
|
|
perform_enqueued_jobs do
|
|
message = create(:message,
|
|
message_type: 'outgoing',
|
|
account: conversation.account,
|
|
inbox: conversation.inbox,
|
|
conversation: conversation,
|
|
sender: agent_bot,
|
|
created_at: created_at)
|
|
end
|
|
message
|
|
end
|
|
|
|
it 'calculates reply time from the most recent customer message after bot response' do
|
|
# Initial conversation: customer message -> agent first reply (to establish first_reply_created_at)
|
|
create_customer_message(conversation, created_at: 10.hours.ago)
|
|
create_agent_message(conversation, created_at: 9.hours.ago)
|
|
|
|
# Customer message 1
|
|
create_customer_message(conversation, created_at: 5.hours.ago)
|
|
|
|
# Bot responds
|
|
create_bot_message(conversation, created_at: 4.hours.ago)
|
|
|
|
# Customer message 2 (after bot response) - should reset waiting_since
|
|
create_customer_message(conversation, created_at: 2.hours.ago)
|
|
|
|
# Human agent replies - should create reply_time event from customer message 2
|
|
create_agent_message(conversation, created_at: 1.hour.ago)
|
|
|
|
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
|
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
|
|
# Reply time should be 1 hour (from customer message 2 to agent reply)
|
|
expect(reply_events.first.value).to be_within(60).of(3600)
|
|
end
|
|
|
|
it 'handles multiple bot responses before customer messages again' do
|
|
# Initial conversation: customer message -> agent first reply
|
|
create_customer_message(conversation, created_at: 10.hours.ago)
|
|
create_agent_message(conversation, created_at: 9.hours.ago)
|
|
|
|
# Customer message 1
|
|
create_customer_message(conversation, created_at: 6.hours.ago)
|
|
|
|
# Bot responds multiple times
|
|
create_bot_message(conversation, created_at: 5.hours.ago)
|
|
create_bot_message(conversation, created_at: 4.hours.ago)
|
|
|
|
# Customer message 2 (after multiple bot responses) - should reset waiting_since
|
|
create_customer_message(conversation, created_at: 2.hours.ago)
|
|
|
|
# Human agent replies
|
|
create_agent_message(conversation, created_at: 1.hour.ago)
|
|
|
|
reply_events = account.reporting_events.where(name: 'reply_time', conversation_id: conversation.id)
|
|
expect(reply_events.count).to eq(1) # Only the second agent reply creates a reply_time event
|
|
# Reply time should be 1 hour (from customer message 2 to agent reply)
|
|
expect(reply_events.first.value).to be_within(60).of(3600)
|
|
end
|
|
end
|
|
end
|
|
end
|