diff --git a/app/jobs/inboxes/fetch_app_store_reviews_job.rb b/app/jobs/inboxes/fetch_app_store_reviews_job.rb index 87732162c..75453c14f 100644 --- a/app/jobs/inboxes/fetch_app_store_reviews_job.rb +++ b/app/jobs/inboxes/fetch_app_store_reviews_job.rb @@ -4,9 +4,23 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob def perform(channel) return unless channel.account.feature_enabled?(:channel_app_store) + synced_until = sync_reviews(channel) + return if synced_until.blank? + + channel.update!(last_synced_at: synced_until) + rescue StandardError => e + ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + end + + private + + def sync_reviews(channel) failed = false + synced_dates = [] + 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) rescue StandardError => e failed = true ChatwootExceptionTracker.new(e, account: channel.account).capture_exception @@ -14,8 +28,12 @@ class Inboxes::FetchAppStoreReviewsJob < ApplicationJob return if failed - channel.update!(last_synced_at: Time.current) - rescue StandardError => e - ChatwootExceptionTracker.new(e, account: channel.account).capture_exception + synced_dates.compact.max + end + + def parsed_review_created_at(review_payload) + Time.zone.parse(review_payload.dig('review', 'attributes', 'createdDate').to_s) + rescue StandardError + nil end end diff --git a/app/services/app_store/review_builder.rb b/app/services/app_store/review_builder.rb index 875e2c921..ca5fe751b 100644 --- a/app/services/app_store/review_builder.rb +++ b/app/services/app_store/review_builder.rb @@ -51,7 +51,7 @@ class AppStore::ReviewBuilder end def rating - attributes['rating'].to_i + attributes['rating'].to_i.clamp(0, 5) end def created_at 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 3779e34d7..545a7e8cc 100644 --- a/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb +++ b/spec/jobs/inboxes/fetch_app_store_reviews_job_spec.rb @@ -4,7 +4,15 @@ 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_payload) do + { + 'review' => { + 'id' => 'review-1', + 'attributes' => { 'createdDate' => '2026-05-20T10:00:00-00:00' } + }, + 'response' => nil + } + end let(:review_builder) { instance_double(AppStore::ReviewBuilder, perform: true) } before do @@ -24,7 +32,23 @@ RSpec.describe Inboxes::FetchAppStoreReviewsJob do described_class.perform_now(channel) expect(review_builder).to have_received(:perform) - expect(channel.reload.last_synced_at).to be_present + expect(channel.reload.last_synced_at).to eq(Time.zone.parse('2026-05-20T10:00:00-00:00')) + end + + it 'updates the sync timestamp to the latest fetched review date' do + older_payload = review_payload.deep_dup + newer_payload = review_payload.deep_dup + older_payload['review']['id'] = 'review-1' + older_payload['review']['attributes']['createdDate'] = '2026-05-20T10:00:00-00:00' + newer_payload['review']['id'] = 'review-2' + newer_payload['review']['attributes']['createdDate'] = '2026-05-20T11:00:00-00:00' + + allow(channel).to receive(:fetch_reviews).and_return([newer_payload, older_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-20T11:00:00-00:00')) end it 'captures per-review errors and continues syncing' do diff --git a/spec/services/app_store/review_builder_spec.rb b/spec/services/app_store/review_builder_spec.rb index 3b08409cf..50219caff 100644 --- a/spec/services/app_store/review_builder_spec.rb +++ b/spec/services/app_store/review_builder_spec.rb @@ -74,6 +74,18 @@ RSpec.describe AppStore::ReviewBuilder do expect(review_message.content).to include('★★★★☆ (4/5)') end + it 'clamps the review rating before building the message content' do + invalid_rating_payload = review_payload.deep_dup + invalid_rating_payload['review']['attributes']['rating'] = 7 + invalid_rating_payload['response'] = nil + + described_class.new(review_payload: invalid_rating_payload, channel: channel).perform + + review_message = inbox.conversations.last.messages.incoming.find_by(source_id: 'review-1') + expect(review_message.content).to include('★★★★★ (5/5)') + expect(review_message.content_attributes['app_store']).to include('rating' => 5) + end + it 'falls back to current time when Apple timestamps are blank' do blank_timestamp_payload = review_payload.deep_dup blank_timestamp_payload['review']['attributes']['createdDate'] = ''