fix(app-store): sync updated review responses
This commit is contained in:
@@ -20,7 +20,7 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||
|
||||
channel.fetch_reviews.each do |review_payload|
|
||||
::AppStore::ReviewBuilder.new(review_payload: review_payload, channel: channel).perform
|
||||
synced_dates << parsed_review_created_at(review_payload)
|
||||
synced_dates << payload_synced_at(review_payload)
|
||||
rescue StandardError => e
|
||||
failed = true
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
@@ -28,11 +28,18 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob
|
||||
|
||||
return if failed
|
||||
|
||||
synced_dates.compact.max
|
||||
[channel.last_synced_at, synced_dates.compact.max].compact.max
|
||||
end
|
||||
|
||||
def parsed_review_created_at(review_payload)
|
||||
Time.zone.parse(review_payload.dig('review', 'attributes', 'createdDate').to_s)
|
||||
def payload_synced_at(review_payload)
|
||||
[
|
||||
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||
].compact.max
|
||||
end
|
||||
|
||||
def parsed_timestamp(value)
|
||||
Time.zone.parse(value.to_s)
|
||||
rescue StandardError
|
||||
nil
|
||||
end
|
||||
|
||||
@@ -62,16 +62,24 @@ class AppStoreConnect::Client
|
||||
def fresh_review_payloads(review_payloads, since)
|
||||
return review_payloads if since.blank?
|
||||
|
||||
review_payloads.select { |review_payload| review_created_after?(review_payload, since) }
|
||||
review_payloads.select { |review_payload| review_updated_after?(review_payload, since) }
|
||||
end
|
||||
|
||||
def review_created_after?(review_payload, since)
|
||||
created_at = Time.zone.parse(review_payload.dig('review', 'attributes', 'createdDate').to_s)
|
||||
return true if created_at.blank?
|
||||
def review_updated_after?(review_payload, since)
|
||||
review_updated_at = [
|
||||
parsed_timestamp(review_payload.dig('review', 'attributes', 'createdDate')),
|
||||
parsed_timestamp(review_payload.dig('response', 'attributes', 'lastModifiedDate'))
|
||||
].compact.max
|
||||
|
||||
created_at > since
|
||||
return true if review_updated_at.blank?
|
||||
|
||||
review_updated_at >= since
|
||||
end
|
||||
|
||||
def parsed_timestamp(value)
|
||||
Time.zone.parse(value.to_s)
|
||||
rescue StandardError
|
||||
true
|
||||
nil
|
||||
end
|
||||
|
||||
def get(path, query = {})
|
||||
|
||||
@@ -51,6 +51,41 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do
|
||||
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T11:00:00-00:00'))
|
||||
end
|
||||
|
||||
it 'updates the sync timestamp to the response update date when it is newer than the review date' do
|
||||
payload = review_payload.deep_dup
|
||||
payload['response'] = {
|
||||
'id' => 'response-1',
|
||||
'attributes' => {
|
||||
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
|
||||
}
|
||||
}
|
||||
|
||||
allow(channel).to receive(:fetch_reviews).and_return([payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T12:00:00-00:00'))
|
||||
end
|
||||
|
||||
it 'does not move the sync timestamp backwards when processing an older response update' do
|
||||
channel.update!(last_synced_at: Time.zone.parse('2026-05-20T13:00:00-00:00'))
|
||||
payload = review_payload.deep_dup
|
||||
payload['response'] = {
|
||||
'id' => 'response-1',
|
||||
'attributes' => {
|
||||
'lastModifiedDate' => '2026-05-20T12:00:00-00:00'
|
||||
}
|
||||
}
|
||||
|
||||
allow(channel).to receive(:fetch_reviews).and_return([payload])
|
||||
allow(AppStore::ReviewBuilder).to receive(:new).and_return(review_builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T13:00:00-00:00'))
|
||||
end
|
||||
|
||||
it 'captures per-review errors and continues syncing' do
|
||||
exception_tracker = instance_double(ChatwootExceptionTracker, capture_exception: true)
|
||||
|
||||
|
||||
@@ -110,6 +110,49 @@ RSpec.describe AppStoreConnect::Client do
|
||||
expect(WebMock).not_to have_requested(:get, 'https://api.appstoreconnect.apple.com/v1/apps/123456789/customerReviews?page=2')
|
||||
end
|
||||
|
||||
it 'includes older reviews when the developer response was updated after the sync cursor' 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',
|
||||
attributes: {
|
||||
createdDate: '2026-05-19T10:00:00-00:00'
|
||||
},
|
||||
relationships: {
|
||||
response: {
|
||||
data: {
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
included: [
|
||||
{
|
||||
id: 'response-1',
|
||||
type: 'customerReviewResponses',
|
||||
attributes: {
|
||||
responseBody: 'Updated response',
|
||||
lastModifiedDate: '2026-05-20T11:00:00-00:00'
|
||||
}
|
||||
}
|
||||
]
|
||||
}.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
review_payloads = described_class.new(channel: channel).fetch_reviews(since: Time.zone.parse('2026-05-20T10:00:00-00:00'))
|
||||
|
||||
expect(review_payloads.pluck('review').pluck('id')).to eq(['review-1'])
|
||||
expect(review_payloads.first['response']['attributes']['responseBody']).to eq('Updated response')
|
||||
end
|
||||
|
||||
it 'fetches a fresh cached token for each request' do
|
||||
first_token_service = instance_double(AppStoreConnect::TokenService, token: 'first-token')
|
||||
second_token_service = instance_double(AppStoreConnect::TokenService, token: 'second-token')
|
||||
|
||||
Reference in New Issue
Block a user