Files
chatwoot/spec/jobs/mutex_application_job_spec.rb
Shivam MishraandGitHub d9c07fe2e9 feat: expose onboarding help center generation status (#14671)
This PR adds a reload-safe onboarding Help Center generation status
path. Previously, generation progress was pushed through ActionCable,
which meant the onboarding UI could lose context after a page reload or
missed websocket event. The new endpoint exposes the current generation
id, raw Redis generation state, and Help Center article/category counts
so clients can recover state by fetching from the backend.

**What changed**

- Added an onboarding Help Center generation status endpoint.
- Persisted `help_center_generation_id` when generation is enqueued.
- Removed Help Center generation ActionCable broadcasts completely.
- Kept generation progress in Redis as the backend source of truth.
- Kept Help Center generation out of the onboarding hot path; the
existing onboarding controller does not start generation yet.

**How to test**

1. Start Help Center generation for an account.
2. Fetch the onboarding generation status endpoint and verify it returns
the generation id, Redis state, and article/category counts.
3. Reload the onboarding UI or client state and fetch again to confirm
progress can be recovered without relying on websocket events.
4. Verify skipped/completed generation states are reflected from Redis.

## Related PRs

- https://github.com/chatwoot/chatwoot/pull/14569
- https://github.com/chatwoot/chatwoot/pull/14568
- https://github.com/chatwoot/chatwoot/pull/14567
- https://github.com/chatwoot/chatwoot/pull/14619
- https://github.com/chatwoot/chatwoot/pull/14565 (Primary onboarding
PR)
2026-06-10 16:24:37 +05:30

112 lines
4.0 KiB
Ruby

require 'rails_helper'
RSpec.describe MutexApplicationJob do
let(:lock_manager) { instance_double(Redis::LockManager) }
let(:lock_key) { 'test_key' }
before do
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
allow(lock_manager).to receive(:lock).and_return(true)
allow(lock_manager).to receive(:unlock).and_return(true)
end
describe '#with_lock' do
it 'acquires the lock and yields the block if lock is not acquired' do
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, &b) }.to yield_control
end
it 'acquires the lock with custom timeout' do
expect(lock_manager).to receive(:lock).with(lock_key, 5.seconds).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect { |b| described_class.new.send(:with_lock, lock_key, 5.seconds, &b) }.to yield_control
end
it 'raises LockAcquisitionError if it cannot acquire the lock' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
expect do
described_class.new.send(:with_lock, lock_key) do
# Do nothing
end
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
end
it 'raises StandardError if it execution raises it' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
allow(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
described_class.new.send(:with_lock, lock_key) do
raise StandardError
end
end.to raise_error(StandardError)
end
it 'ensures that the lock is released even if there is an error during block execution' do
expect(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(true)
expect(lock_manager).to receive(:unlock).with(lock_key).and_return(true)
expect do
described_class.new.send(:with_lock, lock_key) { raise StandardError }
end.to raise_error(StandardError)
end
end
describe '.retry_on_lock_conflict' do
let(:job_class) do
Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1, on_exhaustion: :process_without_lock
attr_reader :fallback_args
def perform(lock_key, _payload)
with_lock(lock_key) { raise 'lock should not be acquired' }
end
def process_without_lock(lock_key, payload)
@fallback_args = [lock_key, payload]
end
end
end
let(:payload) { { 'message' => 'hello' } }
before do
stub_const('LockConflictTestJob', job_class)
end
it 'runs the configured handler with the original job arguments when lock retries are exhausted' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
job = job_class.new(lock_key, payload)
expect { job.perform_now }.not_to raise_error
expect(job.fallback_args).to eq([lock_key, payload])
end
context 'without an exhaustion handler' do
let(:job_class) do
Class.new(MutexApplicationJob) do
retry_on_lock_conflict wait: 1.second, attempts: 1
def perform(lock_key)
with_lock(lock_key) { raise 'lock should not be acquired' }
end
end
end
it 'raises the lock acquisition error when retries are exhausted' do
allow(lock_manager).to receive(:lock).with(lock_key, Redis::LockManager::LOCK_TIMEOUT).and_return(false)
expect do
job_class.perform_now(lock_key)
end.to raise_error(StandardError) { |error| expect(error.class.name).to eq('MutexApplicationJob::LockAcquisitionError') }
end
end
end
end