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)
112 lines
4.0 KiB
Ruby
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
|