chore(automations): move specs to a follow-up branch

This commit is contained in:
Tanmay Deep Sharma
2026-07-15 10:57:23 +05:30
parent 5c0cbcbaaa
commit 82f28aa7fb
8 changed files with 0 additions and 520 deletions
@@ -451,61 +451,4 @@ RSpec.describe 'Api::V1::Accounts::AutomationRulesController', type: :request do
end
end
end
describe 'execution_delay handling' do
let(:delayed_rule_params) do
{
name: 'Delayed rule',
event_name: 'conversation_updated',
execution_delay: 240,
conditions: [{ attribute_key: 'status', filter_operator: 'equal_to', values: ['pending'], query_operator: nil }],
actions: [{ action_name: 'add_label', action_params: ['stale'] }]
}
end
context 'when the delayed_automations feature is enabled' do
before { account.enable_features!('delayed_automations') }
it 'persists and serializes execution_delay' do
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: delayed_rule_params
expect(response).to have_http_status(:success)
body = JSON.parse(response.body, symbolize_names: true)
expect(body[:execution_delay]).to eq(240)
expect(account.automation_rules.last.execution_delay).to eq(240)
end
it 'copies execution_delay on clone' do
automation_rule = create(:automation_rule, account: account, execution_delay: 240)
post "/api/v1/accounts/#{account.id}/automation_rules/#{automation_rule.id}/clone",
headers: administrator.create_new_auth_token
expect(response).to have_http_status(:success)
expect(account.automation_rules.last.execution_delay).to eq(240)
end
end
context 'when the delayed_automations feature is disabled' do
it 'rejects a payload carrying execution_delay with 422' do
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: delayed_rule_params
expect(response).to have_http_status(:unprocessable_entity)
expect(account.automation_rules.count).to eq(0)
end
it 'still accepts payloads without execution_delay' do
post "/api/v1/accounts/#{account.id}/automation_rules",
headers: administrator.create_new_auth_token,
params: delayed_rule_params.except(:execution_delay)
expect(response).to have_http_status(:success)
expect(account.automation_rules.last.execution_delay).to be_nil
end
end
end
end
@@ -1,10 +0,0 @@
FactoryBot.define do
factory :automation_rule_pending_execution do
account
automation_rule { association :automation_rule, account: account }
conversation { association :conversation, account: account }
episode_key { "status:#{Time.current.to_i}" }
due_at { 1.hour.from_now }
status { :pending }
end
end
@@ -1,139 +0,0 @@
require 'rails_helper'
RSpec.describe AutomationRules::ProcessPendingExecutionJob do
subject(:job) { described_class.new }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account, status: :pending) }
let(:rule) do
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
conditions: [{ 'values' => ['pending'], 'attribute_key' => 'status', 'query_operator' => nil,
'filter_operator' => 'equal_to' }],
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
end
let(:pending_execution) do
AutomationRulePendingExecution.schedule(rule: rule, conversation: conversation)
AutomationRulePendingExecution.last
end
before do
GlobalConfig.clear_cache
account.enable_features!('delayed_automations')
end
it 'runs the actions and marks the row executed when every guard passes' do
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_executed
expect(conversation.reload.label_list).to include('stale')
end
it 'skips with rule_inactive when the rule was disabled' do
rule.update!(active: false)
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_skipped
expect(pending_execution.skip_reason).to eq('rule_inactive')
expect(conversation.reload.label_list).to be_empty
end
it 'skips with flag_disabled when the account flag was turned off' do
account.disable_features!('delayed_automations')
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_skipped
expect(pending_execution.skip_reason).to eq('flag_disabled')
end
it 'skips with episode_moved when the conversation left the armed status' do
pending_execution
conversation.update!(status: :resolved)
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_skipped
expect(pending_execution.skip_reason).to eq('episode_moved')
expect(conversation.reload.label_list).to be_empty
end
it 'skips with conditions_changed when the re-check no longer matches the edited rule' do
pending_execution
# Rule edited while pending: the re-check enforces the current conditions (by design).
rule.update!(conditions: [{ 'values' => ['open'], 'attribute_key' => 'status', 'query_operator' => nil,
'filter_operator' => 'equal_to' }])
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_skipped
expect(pending_execution.skip_reason).to eq('conditions_changed')
end
it 'skips with expired when the row is past the due window' do
pending_execution.update!(due_at: 4.days.ago)
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_skipped
expect(pending_execution.skip_reason).to eq('expired')
expect(conversation.reload.label_list).to be_empty
end
it 'leaves the row untouched without executing when the kill switch is set' do
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
GlobalConfig.clear_cache
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_pending
expect(conversation.reload.label_list).to be_empty
end
it 'runs the actions once when the same row is processed twice concurrently' do
allow(AutomationRules::ActionService).to receive(:new).and_call_original
duplicate = AutomationRulePendingExecution.find(pending_execution.id)
job.perform(pending_execution.reload)
described_class.new.perform(duplicate)
expect(AutomationRules::ActionService).to have_received(:new).once
expect(pending_execution.reload).to be_executed
end
it 'leaves the row processing and reports the error when an action blows up' do
allow(AutomationRules::ActionService).to receive(:new).and_raise(StandardError, 'boom')
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
job.perform(pending_execution.reload)
expect(pending_execution.reload).to be_processing
expect(ChatwootExceptionTracker).to have_received(:new)
end
it 'sends the follow-up exactly once for the reply-chase story' do
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
row = AutomationRulePendingExecution.last
job.perform(row.reload)
expect(row.reload).to be_executed
expect(conversation.messages.outgoing.pluck(:content)).to include('Just checking in')
end
it 'cancels the follow-up when the customer replied before it was due' do
message_rule = create(:automation_rule, account: account, event_name: 'message_created', execution_delay: 60,
conditions: [{ 'values' => ['outgoing'], 'attribute_key' => 'message_type',
'query_operator' => nil, 'filter_operator' => 'equal_to' }],
actions: [{ 'action_name' => 'send_message', 'action_params' => ['Just checking in'] }])
agent_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
AutomationRulePendingExecution.schedule(rule: message_rule, conversation: conversation, message: agent_reply)
row = AutomationRulePendingExecution.last
create(:message, conversation: conversation, account: account, message_type: :incoming)
job.perform(row.reload)
expect(row.reload).to be_skipped
expect(row.skip_reason).to eq('episode_moved')
expect(conversation.messages.outgoing.pluck(:content)).not_to include('Just checking in')
end
end
@@ -1,48 +0,0 @@
require 'rails_helper'
RSpec.describe AutomationRules::TriggerPendingExecutionsJob do
subject(:job) { described_class.new }
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
before { GlobalConfig.clear_cache }
it 'enqueues a per-row job for due pending rows but not future ones' do
due_row = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once).with(due_row)
end
it 're-enqueues stale processing rows so they get retried' do
stale_row = travel_to(20.minutes.ago) do
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing, due_at: 19.minutes.from_now)
end
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).with(stale_row)
end
it 'caps enqueues at the configured sweep limit' do
create(:installation_config, name: 'AUTOMATION_PENDING_EXECUTIONS_SWEEP_LIMIT', serialized_value: { value: 1 }.with_indifferent_access)
create_list(:automation_rule_pending_execution, 2, account: account, due_at: 1.minute.ago)
expect { job.perform }.to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob).exactly(:once)
end
it 'purges terminal rows past the retention window' do
old_row = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
job.perform
expect { old_row.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'does nothing when the kill switch is set' do
create(:installation_config, name: 'DISABLE_DELAYED_AUTOMATIONS', serialized_value: { value: true }.with_indifferent_access)
GlobalConfig.clear_cache
create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
expect { job.perform }.not_to have_enqueued_job(AutomationRules::ProcessPendingExecutionJob)
end
end
@@ -247,37 +247,4 @@ describe AutomationRuleListener do
end
end
end
describe 'delayed rules' do
let!(:automation_rule) { create(:automation_rule, event_name: 'conversation_updated', account: account, execution_delay: 60) }
let(:event) do
Events::Base.new('conversation_updated', Time.zone.now, { conversation: conversation, changed_attributes: {} })
end
before { allow(condition_match).to receive(:present?).and_return(true) }
context 'when the delayed_automations feature is enabled' do
before { account.enable_features!('delayed_automations') }
it 'records a pending execution instead of running actions' do
expect { listener.conversation_updated(event) }.to change(AutomationRulePendingExecution, :count).by(1)
expect(AutomationRules::ActionService).not_to have_received(:new)
expect(AutomationRulePendingExecution.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
end
it 'still runs rules without a delay immediately' do
automation_rule.update!(execution_delay: nil)
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
expect(AutomationRules::ActionService).to have_received(:new).with(automation_rule, account, conversation)
end
end
context 'when the delayed_automations feature is disabled' do
it 'neither arms a pending execution nor falls back to immediate execution' do
expect { listener.conversation_updated(event) }.not_to change(AutomationRulePendingExecution, :count)
expect(AutomationRules::ActionService).not_to have_received(:new)
end
end
end
end
@@ -1,171 +0,0 @@
require 'rails_helper'
RSpec.describe AutomationRulePendingExecution do
let(:account) { create(:account) }
let(:conversation) { create(:conversation, account: account) }
let(:rule) do
create(:automation_rule, account: account, event_name: 'conversation_updated', execution_delay: 60,
actions: [{ 'action_name' => 'add_label', 'action_params' => ['stale'] }])
end
describe '.episode_key_for' do
it 'derives status episodes from status_changed_at' do
expect(described_class.episode_key_for(conversation, nil)).to eq("status:#{conversation.status_changed_at.to_f}")
end
it 'falls back to created_at when status_changed_at is blank' do
conversation.update!(status_changed_at: nil)
expect(described_class.episode_key_for(conversation.reload, nil)).to eq("status:#{conversation.created_at.to_f}")
end
it 'derives awaiting_agent episodes from waiting_since for incoming messages' do
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
expect(described_class.episode_key_for(conversation.reload, message)).to eq("awaiting_agent:#{conversation.waiting_since.to_i}")
end
it 'derives reply_chase episodes from the max incoming message id for outgoing messages' do
incoming = create(:message, conversation: conversation, account: account, message_type: :incoming)
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq("reply_chase:#{incoming.id}")
end
it 'uses 0 for reply_chase when there is no incoming message' do
outgoing = create(:message, conversation: conversation, account: account, message_type: :outgoing)
expect(described_class.episode_key_for(conversation.reload, outgoing)).to eq('reply_chase:0')
end
end
describe '.schedule' do
it 'creates a pending row due after the rule delay' do
described_class.schedule(rule: rule, conversation: conversation)
row = described_class.last
expect(row).to have_attributes(account_id: account.id, conversation_id: conversation.id, status: 'pending')
expect(row.due_at).to be_within(5.seconds).of(60.minutes.from_now)
end
it 'does not reset the clock for a repeated status episode' do
described_class.schedule(rule: rule, conversation: conversation)
original_due_at = described_class.last.due_at
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation) }
expect(described_class.count).to eq(1)
expect(described_class.last.due_at).to be_within(1.second).of(original_due_at)
end
it 'moves the clock and anchor for a repeated reply_chase episode' do
first_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
described_class.schedule(rule: rule, conversation: conversation, message: first_reply)
second_reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
travel_to(30.minutes.from_now) do
described_class.schedule(rule: rule, conversation: conversation, message: second_reply)
expect(described_class.count).to eq(1)
expect(described_class.last.message_id).to eq(second_reply.id)
expect(described_class.last.due_at).to be_within(5.seconds).of(60.minutes.from_now)
end
end
it 'does not re-arm an executed reply_chase episode' do
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
described_class.schedule(rule: rule, conversation: conversation, message: reply)
described_class.last.update!(status: :executed)
described_class.schedule(rule: rule, conversation: conversation, message: reply)
expect(described_class.count).to eq(1)
expect(described_class.last).to be_executed
end
it 'does not reset the clock for a repeated awaiting_agent episode' do
first_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
described_class.schedule(rule: rule, conversation: conversation, message: first_message)
original_due_at = described_class.last.due_at
second_message = create(:message, conversation: conversation, account: account, message_type: :incoming)
travel_to(30.minutes.from_now) { described_class.schedule(rule: rule, conversation: conversation, message: second_message) }
expect(described_class.count).to eq(1)
expect(described_class.last.due_at).to be_within(1.second).of(original_due_at)
end
end
describe '#episode_current?' do
it 'is true while the conversation stays in the armed status' do
described_class.schedule(rule: rule, conversation: conversation)
expect(described_class.last.episode_current?).to be(true)
end
it 'is false after a status transition' do
described_class.schedule(rule: rule, conversation: conversation)
conversation.update!(status: :resolved)
expect(described_class.last.reload.episode_current?).to be(false)
end
it 'is false for awaiting_agent episodes once the agent replies (waiting_since cleared)' do
message = create(:message, conversation: conversation, account: account, message_type: :incoming)
described_class.schedule(rule: rule, conversation: conversation, message: message)
conversation.update!(waiting_since: nil)
expect(described_class.last.episode_current?).to be(false)
end
it 'is false for reply_chase episodes once the customer replies' do
reply = create(:message, conversation: conversation, account: account, message_type: :outgoing)
described_class.schedule(rule: rule, conversation: conversation, message: reply)
create(:message, conversation: conversation, account: account, message_type: :incoming)
expect(described_class.last.episode_current?).to be(false)
end
end
describe '#claim!' do
let(:row) { create(:automation_rule_pending_execution, account: account, conversation: conversation) }
it 'claims a pending row exactly once so a duplicate enqueue cannot double-fire' do
expect(row.claim!).to be(true)
expect(row.reload).to be_processing
expect(described_class.find(row.id).claim!).to be(false)
end
it 'does not claim terminal rows' do
row.update!(status: :executed)
expect(row.claim!).to be(false)
end
it 'reclaims a processing row only after its lock goes stale' do
row.update!(status: :processing)
expect(row.claim!).to be(false)
travel_to(20.minutes.from_now) { expect(row.claim!).to be(true) }
end
end
describe '.sweepable' do
it 'selects due pending rows and stale processing rows, but not future or fresh ones' do
due = create(:automation_rule_pending_execution, account: account, conversation: conversation, due_at: 1.minute.ago)
create(:automation_rule_pending_execution, account: account, due_at: 1.hour.from_now)
create(:automation_rule_pending_execution, account: account, status: :processing)
stale = travel_to(20.minutes.ago) do
create(:automation_rule_pending_execution, account: account, conversation: conversation, status: :processing)
end
expect(described_class.sweepable).to contain_exactly(due, stale)
end
end
describe '.purge_terminal!' do
it 'deletes terminal rows past the retention window and keeps everything else' do
old_executed = travel_to(31.days.ago) { create(:automation_rule_pending_execution, account: account, status: :executed) }
recent_skipped = create(:automation_rule_pending_execution, account: account, status: :skipped)
pending = create(:automation_rule_pending_execution, account: account, conversation: conversation)
described_class.purge_terminal!
expect(described_class.pluck(:id)).to contain_exactly(recent_skipped.id, pending.id)
expect { old_executed.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
-38
View File
@@ -137,42 +137,4 @@ RSpec.describe AutomationRule do
end
end
end
describe 'execution_delay validations' do
let(:rule) { build(:automation_rule, account: create(:account)) }
it 'allows nil (immediate execution)' do
rule.execution_delay = nil
expect(rule).to be_valid
end
it 'allows delays between 10 minutes and 30 days' do
rule.execution_delay = 240
expect(rule).to be_valid
end
it 'rejects delays below 10 minutes' do
rule.execution_delay = 5
expect(rule).not_to be_valid
expect(rule.errors[:execution_delay]).to be_present
end
it 'rejects delays above 30 days' do
rule.execution_delay = 43_201
expect(rule).not_to be_valid
end
it 'rejects non-integer delays' do
rule.execution_delay = 10.5
expect(rule).not_to be_valid
end
it 'rejects a delay combined with an attribute_changed condition' do
rule.execution_delay = 60
rule.conditions = [{ 'attribute_key' => 'status', 'filter_operator' => 'attribute_changed',
'values' => { 'from' => ['open'], 'to' => ['pending'] }, 'query_operator' => nil }]
expect(rule).not_to be_valid
expect(rule.errors[:execution_delay]).to include('cannot be used with attribute_changed conditions.')
end
end
end
-24
View File
@@ -1228,28 +1228,4 @@ RSpec.describe Conversation do
end
end
end
describe '#status_changed_at' do
let(:conversation) { create(:conversation) }
it 'is set on create' do
expect(conversation.status_changed_at).to be_present
end
it 'is updated on every status transition' do
original = conversation.status_changed_at
travel_to(1.hour.from_now) { conversation.update!(status: :resolved) }
expect(conversation.reload.status_changed_at).to be > original
end
it 'is untouched by non-status saves' do
original = conversation.status_changed_at
travel_to(1.hour.from_now) { conversation.update!(priority: :high) }
expect(conversation.reload.status_changed_at).to be_within(1.second).of(original)
end
end
end