From b93cb5b654d869fed21f9186a45cbe5ec553d0a2 Mon Sep 17 00:00:00 2001 From: Muhsin <12408980+muhsin-k@users.noreply.github.com> Date: Wed, 3 Jun 2026 20:23:26 +0400 Subject: [PATCH] fix(app-store): sync updated review responses --- .../inboxes/fetch_app_store_reviews_job.rb | 15 +++++-- app/services/app_store_connect/client.rb | 20 ++++++--- .../fetch_app_store_reviews_job_spec.rb | 35 +++++++++++++++ .../services/app_store_connect/client_spec.rb | 43 +++++++++++++++++++ 4 files changed, 103 insertions(+), 10 deletions(-) diff --git a/app/jobs/inboxes/fetch_app_store_reviews_job.rb b/app/jobs/inboxes/fetch_app_store_reviews_job.rb index 75453c14f..212f87a51 100644 --- a/app/jobs/inboxes/fetch_app_store_reviews_job.rb +++ b/app/jobs/inboxes/fetch_app_store_reviews_job.rb @@ -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 diff --git a/app/services/app_store_connect/client.rb b/app/services/app_store_connect/client.rb index f4d2a84b5..a5e0e3f3b 100644 --- a/app/services/app_store_connect/client.rb +++ b/app/services/app_store_connect/client.rb @@ -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 = {}) diff --git a/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb b/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb index 545a7e8cc..2e3c7eb1d 100644 --- a/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb +++ b/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb @@ -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) diff --git a/spec/services/app_store_connect/client_spec.rb b/spec/services/app_store_connect/client_spec.rb index 235f2c9e0..e87f1229f 100644 --- a/spec/services/app_store_connect/client_spec.rb +++ b/spec/services/app_store_connect/client_spec.rb @@ -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')