Merge branch 'develop' into feat/app-store-reviews
This commit is contained in:
@@ -130,17 +130,27 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
end
|
||||
|
||||
context 'when the failure is permanent' do
|
||||
# `discard_on PermanentCrawlError` swallows the error in `perform_now`
|
||||
# under normal conditions, but Zeitwerk reloading in CI can break the
|
||||
# rescue_handlers chain so the error escapes. The behavioural contract
|
||||
# we care about — no retries, correct document state — holds either
|
||||
# way, so tolerate both.
|
||||
def run_job
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
rescue StandardError => e
|
||||
# discard_on may have failed to swallow it; the contract still holds.
|
||||
raise unless e.class.name == 'Captain::Tools::SimplePageCrawlParserJob::PermanentCrawlError' # rubocop:disable Style/ClassEqualityComparison
|
||||
end
|
||||
|
||||
before do
|
||||
allow(crawler).to receive(:status_code).and_return(404)
|
||||
end
|
||||
|
||||
it 'does not retry a discovered link that was never persisted' do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to change(assistant.documents, :count)
|
||||
it 'does not persist a discovered link that was never stored' do
|
||||
expect { run_job }.not_to change(assistant.documents, :count)
|
||||
end
|
||||
|
||||
it 'marks an existing document as available and failed without raising' do
|
||||
it 'marks an existing document as available and failed' do
|
||||
document = create(
|
||||
:captain_document,
|
||||
assistant: assistant,
|
||||
@@ -150,9 +160,7 @@ RSpec.describe Captain::Tools::SimplePageCrawlParserJob, type: :job do
|
||||
)
|
||||
|
||||
freeze_time do
|
||||
expect do
|
||||
described_class.perform_now(assistant_id: assistant.id, page_link: page_link)
|
||||
end.not_to raise_error
|
||||
run_job
|
||||
|
||||
expect(document.reload).to have_attributes(
|
||||
status: 'available',
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleGenerationJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Hello', 'urls' => ['https://x.test/a', 'https://evil.test/hallucinated'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'World', 'urls' => ['https://x.test/b'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
clear_enqueued_jobs
|
||||
curator = instance_double(Onboarding::HelpCenterCurator, perform: curated_plan)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).with(account: account).and_return(curator)
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'happy path' do
|
||||
it 'creates categories, starts state with total/finished, and fans out article payloads' do
|
||||
expect do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
end.to change { portal.categories.count }.by(1)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'generating', 'total' => '2', 'finished' => '0'
|
||||
)
|
||||
expect(enqueued_jobs).to include(
|
||||
a_hash_including(
|
||||
'job_class' => Onboarding::HelpCenterArticleWriterJob.name,
|
||||
'arguments' => array_including(
|
||||
account.id,
|
||||
portal.id,
|
||||
admin.id,
|
||||
generation_id,
|
||||
hash_including(
|
||||
'article' => hash_including(
|
||||
'title' => 'Hello',
|
||||
'urls' => ['https://x.test/a'],
|
||||
'category_id' => portal.categories.first.id
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'orphan article filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a', 'https://x.test/b'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Valid', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles whose category was not emitted alongside them' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Valid'))
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'article URL filtering' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'allowed_urls' => ['https://x.test/a'],
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [
|
||||
{ 'title' => 'Approved', 'urls' => ['https://x.test/a'], 'category_name' => 'Getting Started' },
|
||||
{ 'title' => 'Hallucinated', 'urls' => ['https://evil.test/hallucinated'], 'category_name' => 'Getting Started' }
|
||||
]
|
||||
}
|
||||
end
|
||||
|
||||
it 'drops articles with no approved source urls before fanout' do
|
||||
perform_enqueued_jobs(only: described_class) { described_class.perform_later(*job_args) }
|
||||
|
||||
writer_jobs = enqueued_jobs.select { |job| job['job_class'] == Onboarding::HelpCenterArticleWriterJob.name }
|
||||
expect(writer_jobs.size).to eq(1)
|
||||
expect(writer_jobs.first['arguments']).to include(
|
||||
hash_including('article' => hash_including('title' => 'Approved', 'urls' => ['https://x.test/a']))
|
||||
)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('total' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'transaction rollback' do
|
||||
let(:curated_plan) do
|
||||
{
|
||||
'categories' => [{ 'name' => 'Getting Started', 'description' => 'desc' }],
|
||||
'articles' => [{ 'title' => 'Orphan', 'urls' => ['https://x.test/b'], 'category_name' => 'NonExistent' }]
|
||||
}
|
||||
end
|
||||
|
||||
it 'leaves zero categories and marks state skipped when no article can be stamped' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(portal.categories.count).to eq(0)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no articles after category or URL filtering'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'idempotency' do
|
||||
it 'no-ops when state already exists for this generation' do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to(change { portal.categories.count })
|
||||
expect(Onboarding::HelpCenterCurator).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'curation skipped' do
|
||||
it 'records skip_reason and transitions to skipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'skipped', 'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'firecrawl retries' do
|
||||
it 'transitions to skipped after retries exhaust' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(Firecrawl::FirecrawlError, 'rate limited')
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
perform_enqueued_jobs { described_class.perform_later(*job_args) }
|
||||
|
||||
state = Onboarding::HelpCenterGenerationState.current(generation_id)
|
||||
expect(state['status']).to eq('skipped')
|
||||
expect(state['skip_reason']).to include('firecrawl exhausted')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
it 'broadcasts generation_completed with status: skipped on CurationSkipped' do
|
||||
curator = instance_double(Onboarding::HelpCenterCurator)
|
||||
allow(curator).to receive(:perform).and_raise(
|
||||
Onboarding::HelpCenterErrors::CurationSkipped, 'no website url'
|
||||
)
|
||||
allow(Onboarding::HelpCenterCurator).to receive(:new).and_return(curator)
|
||||
|
||||
payload = hash_including(generation_id: generation_id, status: 'skipped', skip_reason: 'no website url')
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,159 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleWriterJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:article_spec) { { 'urls' => ['https://x.test/a'], 'title' => 'A', 'category_id' => nil } }
|
||||
let(:article_payload) { { 'article' => article_spec } }
|
||||
let(:job_args) { [account.id, portal.id, admin.id, generation_id, article_payload] }
|
||||
let(:state_key) { Onboarding::HelpCenterGenerationState.key(generation_id) }
|
||||
|
||||
before do
|
||||
Onboarding::HelpCenterGenerationState.start(generation_id, total: 2)
|
||||
clear_enqueued_jobs
|
||||
end
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(state_key)
|
||||
end
|
||||
|
||||
describe 'queue' do
|
||||
it 'enqueues on the low queue' do
|
||||
expect { described_class.perform_later(*job_args) }
|
||||
.to have_enqueued_job(described_class).on_queue('low')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'success path' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'invokes the builder and increments the Redis counter' do
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
expect(Onboarding::HelpCenterArticleBuilder).to have_received(:new).with(
|
||||
account: account,
|
||||
portal: portal,
|
||||
user: admin,
|
||||
article: article_spec
|
||||
)
|
||||
end
|
||||
|
||||
it 'flips status to completed once the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'failure handling' do
|
||||
it 'increments the counter on ArticleBuildFailed without re-raising' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
described_class.perform_now(*job_args)
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
|
||||
it 'broadcasts completion when the final writer fails with ArticleBuildFailed' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
Onboarding::HelpCenterGenerationState.record_article_finished(generation_id)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include(
|
||||
'status' => 'completed', 'finished' => '2'
|
||||
)
|
||||
end
|
||||
|
||||
it 're-enqueues itself on transient Firecrawl errors' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'transient'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(described_class).with(*job_args)
|
||||
end
|
||||
|
||||
it 'increments the counter when Firecrawl retries are exhausted' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Firecrawl::FirecrawlError, 'always failing'
|
||||
)
|
||||
|
||||
perform_enqueued_jobs do
|
||||
described_class.perform_later(*job_args)
|
||||
end
|
||||
|
||||
expect(Onboarding::HelpCenterGenerationState.current(generation_id)).to include('finished' => '1')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'broadcasts' do
|
||||
let(:built_article) { instance_double(Article, id: 9876) }
|
||||
|
||||
before do
|
||||
builder = instance_double(Onboarding::HelpCenterArticleBuilder, perform: built_article)
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_return(builder)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.article_generated on success' do
|
||||
payload = hash_including(generation_id: generation_id, article_id: 9876, articles_finished: 1)
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.article_generated', payload)
|
||||
end
|
||||
|
||||
it 'broadcasts help_center.generation_completed when the last writer finishes' do
|
||||
described_class.perform_now(*job_args)
|
||||
payload = hash_including(generation_id: generation_id, status: 'completed')
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', payload)
|
||||
end
|
||||
|
||||
it 'does not broadcast article_generated on builder failure' do
|
||||
allow(Onboarding::HelpCenterArticleBuilder).to receive(:new).and_raise(
|
||||
Onboarding::HelpCenterErrors::ArticleBuildFailed, 'no source urls'
|
||||
)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with(anything, 'help_center.article_generated', anything)
|
||||
end
|
||||
|
||||
it 'broadcasts generation_completed on late retries past total' do
|
||||
described_class.perform_now(*job_args)
|
||||
described_class.perform_now(*job_args)
|
||||
clear_enqueued_jobs
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.to have_enqueued_job(ActionCableBroadcastJob)
|
||||
.with([admin.pubsub_token], 'help_center.generation_completed', hash_including(generation_id: generation_id))
|
||||
end
|
||||
|
||||
it 'skips progress broadcasts when state is missing' do
|
||||
Redis::Alfred.delete(state_key)
|
||||
|
||||
expect { described_class.perform_now(*job_args) }
|
||||
.not_to have_enqueued_job(ActionCableBroadcastJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,18 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterArticleBuilder do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account, role: :administrator) }
|
||||
let(:portal) { create(:portal, account_id: account.id) }
|
||||
|
||||
describe 'source url validation' do
|
||||
it 'requires source urls' do
|
||||
article = { urls: [], title: 'X' }
|
||||
builder = described_class.new(account: account, portal: portal, user: user, article: article)
|
||||
|
||||
expect(Firecrawl::Configuration).not_to receive(:client)
|
||||
expect { builder.perform }
|
||||
.to raise_error(Onboarding::HelpCenterErrors::ArticleBuildFailed, /no source urls/)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCreationService do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'user-confirmed.com' }) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
let(:generation_id) { 'generation-123' }
|
||||
|
||||
before do
|
||||
allow(SecureRandom).to receive(:uuid).and_return(generation_id)
|
||||
end
|
||||
|
||||
describe 'article generation enqueue' do
|
||||
context 'when account has a custom_attributes website' do
|
||||
it 'enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has only a brand_info domain' do
|
||||
let(:account) { create(:account, custom_attributes: { 'brand_info' => { 'domain' => 'enrichment.com' } }) }
|
||||
|
||||
it 'uses the enrichment fallback and enqueues generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
.with(account.id, kind_of(Integer), admin.id, generation_id)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when account has no website url' do
|
||||
let(:account) { create(:account, custom_attributes: {}) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a portal already exists' do
|
||||
before { create(:portal, account_id: account.id) }
|
||||
|
||||
it 'does not enqueue generation' do
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.not_to have_enqueued_job(Onboarding::HelpCenterArticleGenerationJob)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when portal creation fails' do
|
||||
it 'raises the error' do
|
||||
allow(account.portals).to receive(:create!).and_raise(ActiveRecord::RecordInvalid)
|
||||
|
||||
expect { described_class.new(account, admin).perform }
|
||||
.to raise_error(ActiveRecord::RecordInvalid)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,41 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterCurator do
|
||||
let(:account) { create(:account, custom_attributes: { 'website' => 'chatwoot.com' }) }
|
||||
let(:links) do
|
||||
[
|
||||
{ 'url' => 'https://chatwoot.com/docs/a', 'title' => 'A' },
|
||||
{ url: 'https://chatwoot.com/docs/b', title: 'B' },
|
||||
'https://chatwoot.com/docs/c'
|
||||
]
|
||||
end
|
||||
let(:llm_response) do
|
||||
{
|
||||
message: {
|
||||
categories: [{ name: 'Docs', description: 'Docs' }],
|
||||
articles: [
|
||||
{ title: 'A', urls: ['https://chatwoot.com/docs/a'], category_name: 'Docs' },
|
||||
{ title: 'B', urls: ['https://chatwoot.com/docs/b'], category_name: 'Docs' },
|
||||
{ title: 'C', urls: ['https://chatwoot.com/docs/c'], category_name: 'Docs' }
|
||||
]
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
firecrawl_client = instance_double(Firecrawl::Client, map: instance_double(Firecrawl::Models::MapData, links: links))
|
||||
llm_service = instance_double(Captain::Llm::HelpCenterCurationService, perform: llm_response)
|
||||
|
||||
allow(Firecrawl::Configuration).to receive(:configured?).and_return(true)
|
||||
allow(Firecrawl::Configuration).to receive(:client).and_return(firecrawl_client)
|
||||
allow(Captain::Llm::HelpCenterCurationService).to receive(:new)
|
||||
.with(account: account, links: links)
|
||||
.and_return(llm_service)
|
||||
end
|
||||
|
||||
it 'extracts allowed urls from Firecrawl string-keyed link hashes' do
|
||||
result = described_class.new(account: account).perform
|
||||
|
||||
expect(result['allowed_urls']).to eq(['https://chatwoot.com/docs/a'])
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Onboarding::HelpCenterGenerationState do
|
||||
let(:generation_id) { 'generation-123' }
|
||||
let(:account_id) { 42 }
|
||||
|
||||
after do
|
||||
Redis::Alfred.delete(described_class.key(generation_id))
|
||||
end
|
||||
|
||||
describe '.start' do
|
||||
it 'stores status, total, finished, and sets a ttl' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
Redis::Alfred.with do |conn|
|
||||
expect(conn.hget(described_class.key(generation_id), 'status')).to eq('generating')
|
||||
expect(conn.hget(described_class.key(generation_id), 'total')).to eq('2')
|
||||
expect(conn.hget(described_class.key(generation_id), 'finished')).to eq('0')
|
||||
expect(conn.ttl(described_class.key(generation_id))).to be_positive
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.record_article_finished' do
|
||||
it 'increments finished and keeps completed true past the final count' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 1, completed: false)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'generating')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 2, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '2')
|
||||
|
||||
expect(described_class.record_article_finished(generation_id)).to eq(finished: 3, completed: true)
|
||||
expect(described_class.current(generation_id)).to include('status' => 'completed', 'finished' => '3')
|
||||
end
|
||||
|
||||
it 'raises Missing when no state exists for the generation' do
|
||||
expect { described_class.record_article_finished(generation_id) }
|
||||
.to raise_error(described_class::Missing)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.skip' do
|
||||
it 'stores status and reason' do
|
||||
described_class.start(generation_id, total: 2)
|
||||
described_class.skip(generation_id, reason: 'no website url')
|
||||
|
||||
expect(described_class.current(generation_id)).to include(
|
||||
'status' => 'skipped',
|
||||
'skip_reason' => 'no website url'
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
describe '.current' do
|
||||
it 'returns nil when no state exists' do
|
||||
expect(described_class.current(generation_id)).to be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,22 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_app_store, class: 'Channel::AppStore' do
|
||||
account
|
||||
app_id { SecureRandom.random_number(1_000_000_000..9_999_999_999).to_s }
|
||||
bundle_id { 'com.example.app' }
|
||||
app_name { 'Example App' }
|
||||
issuer_id { SecureRandom.uuid }
|
||||
key_id { SecureRandom.alphanumeric(10).upcase }
|
||||
private_key do
|
||||
key = OpenSSL::PKey::EC.generate('prime256v1')
|
||||
key.to_pem
|
||||
end
|
||||
|
||||
to_create { |instance| instance.save!(validate: false) }
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -24,10 +24,11 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_return(3)
|
||||
|
||||
expect(Rails.logger).to receive(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
allow(Rails.logger).to receive(:info)
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
|
||||
expect(Rails.logger).to have_received(:info).with("Assigned 3 conversations for inbox #{inbox.id}")
|
||||
end
|
||||
|
||||
it 'uses custom bulk limit from environment' do
|
||||
@@ -67,16 +68,40 @@ RSpec.describe AutoAssignment::AssignmentJob, type: :job do
|
||||
service = instance_double(AutoAssignment::AssignmentService)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new).and_return(service)
|
||||
allow(service).to receive(:perform_bulk_assignment).and_raise(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to receive(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
allow(Rails.logger).to receive(:error)
|
||||
|
||||
expect do
|
||||
described_class.new.perform(inbox_id: inbox.id)
|
||||
end.to raise_error(StandardError, 'Something went wrong')
|
||||
|
||||
expect(Rails.logger).to have_received(:error).with("Bulk assignment failed for inbox #{inbox.id}: Something went wrong")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.enqueue_for_inbox' do
|
||||
after { Redis::Alfred.delete(format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)) }
|
||||
|
||||
it 'enqueues one run per inbox and coalesces concurrent triggers' do
|
||||
allow(described_class).to receive(:perform_later).and_return(true)
|
||||
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(true)
|
||||
expect(described_class.enqueue_for_inbox(inbox.id)).to be(false)
|
||||
expect(described_class).to have_received(:perform_later).once
|
||||
end
|
||||
|
||||
it 'does not release a newer run marker when its own token is stale' do
|
||||
key = format(Redis::Alfred::AUTO_ASSIGNMENT_IN_FLIGHT_KEY, inbox_id: inbox.id)
|
||||
Redis::Alfred.set(key, 'newer-token', ex: 300)
|
||||
allow(AutoAssignment::AssignmentService).to receive(:new)
|
||||
.and_return(instance_double(AutoAssignment::AssignmentService, perform_bulk_assignment: 0))
|
||||
|
||||
described_class.new.perform(inbox_id: inbox.id, token: 'stale-token')
|
||||
|
||||
expect(Redis::Alfred.get(key)).to eq('newer-token')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'job configuration' do
|
||||
it 'is queued in the default queue' do
|
||||
expect(described_class.queue_name).to eq('default')
|
||||
|
||||
@@ -29,7 +29,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
it 'queues assignment job for eligible inboxes' do
|
||||
inbox_assignment_policy # ensure it exists
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -51,8 +51,8 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
|
||||
allow(Account).to receive(:find_in_batches).and_yield([account]).and_yield([account2])
|
||||
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:perform_later).with(inbox_id: inbox2.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox.id)
|
||||
expect(AutoAssignment::AssignmentJob).to receive(:enqueue_for_inbox).with(inbox2.id)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -65,7 +65,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not queue assignment job' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
@@ -78,7 +78,7 @@ RSpec.describe AutoAssignment::PeriodicAssignmentJob, type: :job do
|
||||
end
|
||||
|
||||
it 'does not process the account' do
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:perform_later)
|
||||
expect(AutoAssignment::AssignmentJob).not_to receive(:enqueue_for_inbox)
|
||||
|
||||
described_class.new.perform
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewInboxesJob do
|
||||
let(:account) { create(:account) }
|
||||
let(:suspended_account) { create(:account, status: 'suspended') }
|
||||
let(:due_channel) { create(:channel_app_store, account: account, last_synced_at: 2.hours.ago) }
|
||||
let(:fresh_channel) { create(:channel_app_store, account: account, last_synced_at: 10.minutes.ago) }
|
||||
let(:suspended_channel) { create(:channel_app_store, account: suspended_account, last_synced_at: 2.hours.ago) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later }.to have_enqueued_job(described_class)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'enqueues fetch jobs only for due channels on active accounts' do
|
||||
due_channel
|
||||
fresh_channel
|
||||
suspended_channel
|
||||
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).to receive(:perform_later).with(due_channel).once
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(fresh_channel)
|
||||
expect(Inboxes::FetchAppStoreReviewsJob).not_to receive(:perform_later).with(suspended_channel)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchAppStoreReviewsJob do
|
||||
let(:channel) { create(:channel_app_store, last_synced_at: nil) }
|
||||
let(:review_payload) { { 'review' => { 'id' => 'review-1' }, 'response' => nil } }
|
||||
let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) }
|
||||
|
||||
it 'enqueues the job' do
|
||||
expect { described_class.perform_later(channel) }.to have_enqueued_job(described_class)
|
||||
.with(channel)
|
||||
.on_queue('scheduled_jobs')
|
||||
end
|
||||
|
||||
it 'fetches reviews, builds messages, and updates the sync timestamp' do
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).with(review_payload: review_payload, channel: channel).and_return(review_builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(review_builder).to have_received(:perform)
|
||||
expect(channel.reload.last_synced_at).to be_present
|
||||
end
|
||||
|
||||
it 'captures per-review errors and continues syncing' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review_payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||
allow(review_builder).to receive(:perform).and_raise(StandardError, 'bad review')
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(exception_tracker).to have_received(:capture_exception)
|
||||
expect(channel.reload.last_synced_at).to be_present
|
||||
end
|
||||
end
|
||||
@@ -122,5 +122,11 @@ RSpec.describe SendReplyJob do
|
||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'Tiktok::SendOnTiktokService')
|
||||
end
|
||||
|
||||
it 'calls ::AppStore::SendOnAppStoreService when its app store message' do
|
||||
app_store_channel = create(:channel_app_store)
|
||||
message = create(:message, conversation: create(:conversation, inbox: app_store_channel.inbox))
|
||||
expect_mapped_service_to_perform(message, 'AppStore::SendOnAppStoreService')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::AppStore do
|
||||
describe 'validations' do
|
||||
it 'normalizes auth fields and stores app metadata from App Store Connect' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(
|
||||
:channel_app_store,
|
||||
account: create(:account),
|
||||
app_id: ' 123456789 ',
|
||||
issuer_id: ' issuer-id ',
|
||||
key_id: ' key-id ',
|
||||
private_key: "-----BEGIN PRIVATE KEY-----\\nabc\\n-----END PRIVATE KEY-----\r\n",
|
||||
app_name: nil,
|
||||
bundle_id: nil
|
||||
)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_return(
|
||||
{
|
||||
'attributes' => {
|
||||
'name' => 'Chatwoot iOS',
|
||||
'bundleId' => 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(channel).to be_valid
|
||||
expect(channel.app_id).to eq('123456789')
|
||||
expect(channel.issuer_id).to eq('issuer-id')
|
||||
expect(channel.key_id).to eq('key-id')
|
||||
expect(channel.private_key).to eq("-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----")
|
||||
expect(channel.app_name).to eq('Chatwoot iOS')
|
||||
expect(channel.bundle_id).to eq('com.chatwoot.app')
|
||||
end
|
||||
|
||||
it 'adds an error when App Store Connect validation fails' do
|
||||
app_store_client = instance_double(AppStoreConnect::Client)
|
||||
channel = build(:channel_app_store)
|
||||
|
||||
allow(channel).to receive(:app_store_client).and_return(app_store_client)
|
||||
allow(app_store_client).to receive(:fetch_app).and_raise(AppStoreConnect::Client::Error, 'invalid credentials')
|
||||
|
||||
expect(channel).not_to be_valid
|
||||
expect(channel.errors[:base]).to include('invalid credentials')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_due?' do
|
||||
it 'returns true when the channel has never synced' do
|
||||
channel = build(:channel_app_store, last_synced_at: nil)
|
||||
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'returns false when the last sync is within the sync interval' do
|
||||
channel = build(:channel_app_store, last_synced_at: 30.minutes.ago)
|
||||
|
||||
expect(channel.sync_due?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,73 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::ReviewBuilder do
|
||||
let(:channel) { create(:channel_app_store) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:review_payload) do
|
||||
{
|
||||
'review' => {
|
||||
'id' => 'review-1',
|
||||
'attributes' => {
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'body' => 'Works well for support.',
|
||||
'territory' => 'US',
|
||||
'reviewerNickname' => 'Reviewer',
|
||||
'createdDate' => '2026-05-20T10:00:00-00:00'
|
||||
},
|
||||
'relationships' => {
|
||||
'response' => {
|
||||
'data' => {
|
||||
'id' => 'response-1',
|
||||
'type' => 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
'response' => {
|
||||
'id' => 'response-1',
|
||||
'attributes' => {
|
||||
'responseBody' => 'Thanks for the feedback.',
|
||||
'state' => 'PUBLISHED',
|
||||
'lastModifiedDate' => '2026-05-20T11:00:00-00:00'
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates a conversation with an incoming review and outgoing developer response' do
|
||||
expect { described_class.new(review_payload: review_payload, channel: channel).perform }
|
||||
.to change(inbox.conversations, :count).by(1)
|
||||
.and change(Message.where(inbox_id: inbox.id), :count).by(2)
|
||||
|
||||
conversation = inbox.conversations.last
|
||||
review_message = conversation.messages.incoming.find_by(source_id: 'review-1')
|
||||
response_message = conversation.messages.outgoing.find_by(source_id: 'response-1')
|
||||
|
||||
expect(conversation.contact_inbox.source_id).to eq('review-1')
|
||||
expect(review_message.content).to include('★★★★☆ (4/5)', 'Helpful app', 'Works well for support.', 'US • Reviewer')
|
||||
expect(review_message.content_attributes['app_store']).to include(
|
||||
'rating' => 4,
|
||||
'title' => 'Helpful app',
|
||||
'territory' => 'US',
|
||||
'reviewer_nickname' => 'Reviewer'
|
||||
)
|
||||
expect(response_message.content).to eq('Thanks for the feedback.')
|
||||
expect(response_message.status).to eq('delivered')
|
||||
end
|
||||
|
||||
it 'updates an existing review message when Apple returns the same review again' do
|
||||
described_class.new(review_payload: review_payload, channel: channel).perform
|
||||
updated_payload = review_payload.deep_dup
|
||||
updated_payload['review']['attributes']['body'] = 'Updated review body.'
|
||||
|
||||
expect { described_class.new(review_payload: updated_payload, channel: channel).perform }
|
||||
.not_to change(Message.where(inbox_id: inbox.id), :count)
|
||||
|
||||
expect(inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1').content).to include('Updated review body.')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStore::SendOnAppStoreService do
|
||||
let(:channel) { create(:channel_app_store) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: inbox.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'review-1') }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, contact: contact, contact_inbox: contact_inbox, account: inbox.account) }
|
||||
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
let(:exception_tracker) { instance_double(ChatwootExceptionTracker, capture_exception: true) }
|
||||
|
||||
before do
|
||||
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_return(exception_tracker)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates an App Store response for a new reply' do
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Thanks')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Thanks', response_id: nil)
|
||||
expect(message.reload.source_id).to eq('response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'updates the existing App Store response when the conversation already has one' do
|
||||
create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Old reply',
|
||||
source_id: 'response-1')
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Updated reply')
|
||||
|
||||
allow(channel).to receive(:reply_to_review).and_return('response-1')
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(channel).to have_received(:reply_to_review).with('review-1', 'Updated reply', response_id: 'response-1')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'marks the message as failed when attachments are present' do
|
||||
message = create(:message, :with_attachment, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(
|
||||
message,
|
||||
'failed',
|
||||
'Sending attachments is not supported for App Store reviews.'
|
||||
)
|
||||
expect(exception_tracker).to have_received(:capture_exception)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,152 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::Client do
|
||||
let(:channel) { create(:channel_app_store, app_id: '123456789') }
|
||||
let(:token_service) { instance_double(AppStoreConnect::TokenService, token: 'jwt-token') }
|
||||
|
||||
before do
|
||||
allow(AppStoreConnect::TokenService).to receive(:new).with(channel: channel).and_return(token_service)
|
||||
end
|
||||
|
||||
describe '#fetch_app' do
|
||||
it 'fetches the configured app' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.with(headers: { 'Authorization' => 'Bearer jwt-token' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: {
|
||||
id: '123456789',
|
||||
attributes: {
|
||||
name: 'Chatwoot',
|
||||
bundleId: 'com.chatwoot.app'
|
||||
}
|
||||
}
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(described_class.new(channel: channel).fetch_app['id']).to eq('123456789')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_reviews' do
|
||||
it 'fetches reviews and attaches the included developer response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews')
|
||||
.with(query: { include: 'response', limit: '200', sort: '-createdDate' })
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: {
|
||||
data: [
|
||||
{
|
||||
id: 'review-1',
|
||||
type: 'customerReviews',
|
||||
relationships: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
included: [
|
||||
{
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks for the review'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
review_payload = described_class.new(channel: channel).fetch_reviews.first
|
||||
|
||||
expect(review_payload['review']['id']).to eq('review-1')
|
||||
expect(review_payload['response']['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#create_review_response' do
|
||||
it 'creates a response for a review' do
|
||||
stub_request(:post, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Thanks'
|
||||
},
|
||||
relationships: {
|
||||
review: {
|
||||
data: {
|
||||
type: 'customerReviews',
|
||||
id: 'review-1'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 201,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).create_review_response('review-1', 'Thanks')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
describe '#update_review_response' do
|
||||
it 'updates an existing response' do
|
||||
stub_request(:patch, 'https://api.appstoreconnect.apple.com/v1/customerReviewResponses/response-1')
|
||||
.with(
|
||||
body: {
|
||||
data: {
|
||||
type: 'customerReviewResponses',
|
||||
id: 'response-1',
|
||||
attributes: {
|
||||
responseBody: 'Updated response'
|
||||
}
|
||||
}
|
||||
}.to_json
|
||||
)
|
||||
.to_return(
|
||||
status: 200,
|
||||
body: { data: { id: 'response-1' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
response = described_class.new(channel: channel).update_review_response('response-1', 'Updated response')
|
||||
|
||||
expect(response['id']).to eq('response-1')
|
||||
end
|
||||
end
|
||||
|
||||
it 'raises a useful error when Apple returns an error response' do
|
||||
stub_request(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789')
|
||||
.to_return(
|
||||
status: 401,
|
||||
body: {
|
||||
errors: [
|
||||
{
|
||||
detail: 'Provide a properly configured and signed bearer token.'
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect { described_class.new(channel: channel).fetch_app }
|
||||
.to raise_error(AppStoreConnect::Client::Error, /properly configured and signed bearer token/)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,40 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe AppStoreConnect::TokenService do
|
||||
let(:private_key) { OpenSSL::PKey::EC.generate('prime256v1').to_pem }
|
||||
let(:channel) do
|
||||
instance_double(
|
||||
Channel::AppStore,
|
||||
id: 1,
|
||||
updated_at: Time.zone.at(1_700_000_000),
|
||||
issuer_id: 'issuer-id',
|
||||
key_id: 'key-id',
|
||||
private_key: private_key
|
||||
)
|
||||
end
|
||||
|
||||
describe '#token' do
|
||||
it 'generates an App Store Connect JWT with the expected claims and headers' do
|
||||
travel_to Time.zone.local(2026, 5, 22, 9, 0, 0) do
|
||||
token = described_class.new(channel: channel).token
|
||||
payload, header = JWT.decode(token, nil, false)
|
||||
|
||||
expect(payload).to include(
|
||||
'iss' => 'issuer-id',
|
||||
'iat' => Time.current.to_i,
|
||||
'exp' => 19.minutes.from_now.to_i,
|
||||
'aud' => 'appstoreconnect-v1'
|
||||
)
|
||||
expect(header).to include('kid' => 'key-id', 'typ' => 'JWT', 'alg' => 'ES256')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns cached tokens for the channel' do
|
||||
allow(Rails.cache).to receive(:read).with('app_store_connect_token:1:1700000000').and_return('cached-token')
|
||||
|
||||
expect(described_class.new(channel: channel).token).to eq('cached-token')
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user