Update spec
This commit is contained in:
@@ -53,6 +53,7 @@ const mockStore = createStore({
|
||||
voice_enabled: true,
|
||||
},
|
||||
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
||||
16: { id: 16, channel_type: INBOX_TYPES.GOOGLE_PLAY },
|
||||
};
|
||||
return inboxes[id] || null;
|
||||
},
|
||||
@@ -226,6 +227,12 @@ describe('useInbox', () => {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isATiktokChannel).toBe(true);
|
||||
|
||||
// Test Google Play
|
||||
wrapper = mount(createTestComponent(16), {
|
||||
global: { plugins: [mockStore] },
|
||||
});
|
||||
expect(wrapper.vm.isAGooglePlayChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -278,6 +285,7 @@ describe('useInbox', () => {
|
||||
'isAnEmailChannel',
|
||||
'isAnInstagramChannel',
|
||||
'isATiktokChannel',
|
||||
'isAGooglePlayChannel',
|
||||
'voiceCallEnabled',
|
||||
'voiceCallProvider',
|
||||
];
|
||||
|
||||
@@ -249,6 +249,14 @@ export default {
|
||||
];
|
||||
}
|
||||
|
||||
// Google Play has no support for bots, business hours, or CSAT — strip those tabs
|
||||
if (this.isAGooglePlayChannel) {
|
||||
const unsupportedKeys = ['business-hours', 'csat', 'bot-configuration'];
|
||||
visibleToAllChannelTabs = visibleToAllChannelTabs.filter(
|
||||
tab => !unsupportedKeys.includes(tab.key)
|
||||
);
|
||||
}
|
||||
|
||||
return visibleToAllChannelTabs;
|
||||
},
|
||||
currentInboxId() {
|
||||
@@ -282,6 +290,9 @@ export default {
|
||||
if (this.isAnEmailChannel) {
|
||||
return `${this.inbox.name} (${this.inbox.email})`;
|
||||
}
|
||||
if (this.isAGooglePlayChannel && this.inbox.app_id) {
|
||||
return `${this.inbox.name} (${this.inbox.app_id})`;
|
||||
}
|
||||
return this.inbox.name;
|
||||
},
|
||||
canLocktoSingleConversation() {
|
||||
@@ -829,6 +840,7 @@ export default {
|
||||
</SettingsFieldSection>
|
||||
|
||||
<SettingsFieldSection
|
||||
v-if="!isAGooglePlayChannel"
|
||||
:label="$t('INBOX_MGMT.HELP_CENTER.LABEL')"
|
||||
:help-text="$t('INBOX_MGMT.HELP_CENTER.SUB_TEXT')"
|
||||
>
|
||||
@@ -1128,6 +1140,7 @@ export default {
|
||||
</SettingsAccordion>
|
||||
|
||||
<SettingsAccordion
|
||||
v-if="!isAGooglePlayChannel"
|
||||
:title="$t('INBOX_MGMT.CHANNEL_PREFERENCES')"
|
||||
class="mt-6"
|
||||
>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
class Inboxes::FetchGooglePlayReviewInboxesJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
|
||||
# Runs every 15 minutes via cron. Each inbox syncs at most once per Channel::GooglePlay::SYNC_INTERVAL
|
||||
# (one hour) — the extra cron ticks act as quick retries when a sync window is missed.
|
||||
def perform
|
||||
Inbox.where(channel_type: 'Channel::GooglePlay').find_each(batch_size: 100) do |inbox|
|
||||
next if inbox.account.suspended?
|
||||
next unless inbox.channel.sync_due?
|
||||
|
||||
::Inboxes::FetchGooglePlayReviewsJob.perform_later(inbox.channel)
|
||||
end
|
||||
|
||||
@@ -7,6 +7,9 @@ class Inboxes::FetchGooglePlayReviewsJob < ApplicationJob
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
# Stamp the channel so the orchestrator skips it until the sync interval elapses.
|
||||
channel.update!(last_synced_at: Time.current)
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: channel.account).capture_exception
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
# id :bigint not null, primary key
|
||||
# app_id :string not null
|
||||
# provider_config :jsonb not null
|
||||
# last_synced_at :datetime
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
@@ -23,23 +24,44 @@ class Channel::GooglePlay < ApplicationRecord
|
||||
API_BASE_URL = 'https://androidpublisher.googleapis.com/androidpublisher/v3'.freeze
|
||||
# Google Play caps a developer reply at 350 characters
|
||||
MAX_REPLY_LENGTH = 350
|
||||
REVIEWS_PAGE_SIZE = 100
|
||||
# Safety bound — at 100 per page this covers 5,000 reviews. The API only retains the last 7
|
||||
# days, so this is far above any realistic volume and just prevents runaway loops.
|
||||
MAX_REVIEW_PAGES = 50
|
||||
# Each inbox is synced at most once per this window. The cron runs more frequently
|
||||
# so a missed window retries on the next tick.
|
||||
SYNC_INTERVAL = 1.hour
|
||||
|
||||
validates :app_id, presence: true, uniqueness: { scope: :account_id }
|
||||
|
||||
# Pull reviews on the agent's behalf the moment the inbox is wired up so they're not waiting
|
||||
# for the next 15-minute poll. Runs after the surrounding transaction commits so the
|
||||
# associated inbox is guaranteed to be visible.
|
||||
after_create_commit :enqueue_initial_review_fetch
|
||||
|
||||
def name
|
||||
'Google Play'
|
||||
end
|
||||
|
||||
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently
|
||||
def fetch_reviews
|
||||
response = HTTParty.get(
|
||||
"#{API_BASE_URL}/applications/#{app_id}/reviews",
|
||||
headers: authorization_headers,
|
||||
query: { maxResults: 100 }
|
||||
)
|
||||
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
|
||||
def sync_due?
|
||||
last_synced_at.nil? || last_synced_at < SYNC_INTERVAL.ago
|
||||
end
|
||||
|
||||
response.parsed_response['reviews'] || []
|
||||
# Google Play only retains reviews from the last 7 days, hence the channel is polled frequently.
|
||||
# Pages through tokenPagination.nextPageToken until the API stops returning a cursor.
|
||||
def fetch_reviews
|
||||
reviews = []
|
||||
page_token = nil
|
||||
|
||||
MAX_REVIEW_PAGES.times do
|
||||
parsed = fetch_reviews_page(page_token)
|
||||
reviews.concat(parsed['reviews'] || [])
|
||||
|
||||
page_token = parsed.dig('tokenPagination', 'nextPageToken')
|
||||
break if page_token.blank?
|
||||
end
|
||||
|
||||
reviews
|
||||
end
|
||||
|
||||
# Returns a stable source_id for the reply so the outgoing message can be marked as sent.
|
||||
@@ -59,6 +81,20 @@ class Channel::GooglePlay < ApplicationRecord
|
||||
|
||||
private
|
||||
|
||||
def fetch_reviews_page(page_token)
|
||||
query = { maxResults: REVIEWS_PAGE_SIZE }
|
||||
query[:token] = page_token if page_token.present?
|
||||
|
||||
response = HTTParty.get(
|
||||
"#{API_BASE_URL}/applications/#{app_id}/reviews",
|
||||
headers: authorization_headers,
|
||||
query: query
|
||||
)
|
||||
raise "Google Play reviews fetch failed (#{response.code}): #{response.body}" unless response.success?
|
||||
|
||||
response.parsed_response
|
||||
end
|
||||
|
||||
def authorization_headers
|
||||
{ 'Authorization' => "Bearer #{access_token}" }
|
||||
end
|
||||
@@ -67,4 +103,8 @@ class Channel::GooglePlay < ApplicationRecord
|
||||
def access_token
|
||||
Google::RefreshOauthTokenService.new(channel: self).access_token
|
||||
end
|
||||
|
||||
def enqueue_initial_review_fetch
|
||||
::Inboxes::FetchGooglePlayReviewsJob.perform_later(self)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Builds a contact, conversation and incoming message from a single Google Play review.
|
||||
# Builds a contact, conversation and messages from a single Google Play review.
|
||||
# Each review maps to one conversation (keyed on the review id). When a reviewer edits
|
||||
# their review, a fresh incoming message is appended to the same conversation.
|
||||
# Developer replies (whether posted via Chatwoot or directly in Play Console) are mirrored
|
||||
# as outgoing messages, with a source_id that matches what `SendOnGooglePlayService`
|
||||
# produces so Chatwoot-originated replies are deduped on the next poll.
|
||||
class GooglePlay::ReviewBuilder
|
||||
pattr_initialize [:review!, :channel!]
|
||||
|
||||
@@ -10,7 +13,8 @@ class GooglePlay::ReviewBuilder
|
||||
ActiveRecord::Base.transaction do
|
||||
build_contact_inbox
|
||||
build_conversation
|
||||
build_message
|
||||
build_user_message
|
||||
build_developer_message if developer_comment.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -21,7 +25,15 @@ class GooglePlay::ReviewBuilder
|
||||
end
|
||||
|
||||
def user_comment
|
||||
@user_comment ||= Array(review['comments']).filter_map { |comment| comment['userComment'] }.first
|
||||
@user_comment ||= comments_with_key('userComment').first
|
||||
end
|
||||
|
||||
def developer_comment
|
||||
@developer_comment ||= comments_with_key('developerComment').first
|
||||
end
|
||||
|
||||
def comments_with_key(key)
|
||||
Array(review['comments']).filter_map { |comment| comment[key] }
|
||||
end
|
||||
|
||||
def review_id
|
||||
@@ -37,10 +49,16 @@ class GooglePlay::ReviewBuilder
|
||||
end
|
||||
|
||||
# A new lastModified timestamp (edited review) yields a new message in the same conversation
|
||||
def message_source_id
|
||||
def user_message_source_id
|
||||
"#{review_id}::#{user_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
# Must match the format `Channel::GooglePlay#reply_to_review` returns so replies sent through
|
||||
# Chatwoot are not duplicated when the review is re-fetched.
|
||||
def developer_message_source_id
|
||||
"#{review_id}::reply::#{developer_comment.dig('lastModified', 'seconds')}"
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
@contact_inbox = ::ContactInboxWithContactBuilder.new(
|
||||
source_id: review_id,
|
||||
@@ -62,31 +80,76 @@ class GooglePlay::ReviewBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def build_message
|
||||
return if @conversation.messages.exists?(source_id: message_source_id)
|
||||
def build_user_message
|
||||
return if @conversation.messages.exists?(source_id: user_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
sender: @conversation.contact,
|
||||
message_type: :incoming,
|
||||
source_id: message_source_id,
|
||||
source_id: user_message_source_id,
|
||||
content: message_content,
|
||||
content_attributes: review_metadata
|
||||
)
|
||||
end
|
||||
|
||||
def build_developer_message
|
||||
text = developer_comment['text'].to_s.strip
|
||||
return if text.blank?
|
||||
return if @conversation.messages.exists?(source_id: developer_message_source_id)
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: inbox.account_id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :outgoing,
|
||||
source_id: developer_message_source_id,
|
||||
content: text,
|
||||
status: :sent
|
||||
)
|
||||
end
|
||||
|
||||
def message_content
|
||||
stars = ('★' * star_rating) + ('☆' * (5 - star_rating))
|
||||
"#{stars} (#{star_rating}/5)\n\n#{review_text}"
|
||||
[
|
||||
"#{stars} (#{star_rating}/5)",
|
||||
review_text,
|
||||
review_footer
|
||||
].compact_blank.join("\n\n")
|
||||
end
|
||||
|
||||
# A compact line of context shown beneath the review text — device, app version, OS.
|
||||
def review_footer
|
||||
parts = [device_display, app_version_display, os_version_display].compact_blank
|
||||
return nil if parts.empty?
|
||||
|
||||
parts.join(' • ')
|
||||
end
|
||||
|
||||
def device_display
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
metadata['productName'].presence || user_comment['device']
|
||||
end
|
||||
|
||||
def app_version_display
|
||||
version = user_comment['appVersionName']
|
||||
version.present? ? "v#{version}" : nil
|
||||
end
|
||||
|
||||
def os_version_display
|
||||
version = user_comment['androidOsVersion']
|
||||
version.present? ? "Android #{version}" : nil
|
||||
end
|
||||
|
||||
def review_metadata
|
||||
metadata = user_comment['deviceMetadata'] || {}
|
||||
{
|
||||
google_play: {
|
||||
star_rating: star_rating,
|
||||
app_version: user_comment['appVersionName'],
|
||||
device: user_comment['device'],
|
||||
device: device_display,
|
||||
manufacturer: metadata['manufacturer'],
|
||||
android_os_version: user_comment['androidOsVersion'],
|
||||
reviewer_language: user_comment['reviewerLanguage']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,10 +19,12 @@ class MessageTemplates::HookExecutionService
|
||||
::MessageTemplates::Template::EmailCollect.new(conversation: conversation).perform if inbox.enable_email_collect && should_send_email_collect?
|
||||
end
|
||||
|
||||
def should_send_out_of_office_message?
|
||||
def should_send_out_of_office_message? # rubocop:disable Metrics/CyclomaticComplexity, Metrics/AbcSize
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# Google Play replies don't support out-of-office auto-replies; business hours don't apply to review responses
|
||||
return false if inbox.google_play?
|
||||
# should not send for outbound messages
|
||||
return false unless message.incoming?
|
||||
# prevents sending out-of-office message if an agent has sent a message in last 5 minutes
|
||||
@@ -40,6 +42,8 @@ class MessageTemplates::HookExecutionService
|
||||
return false if conversation.campaign.present?
|
||||
# should not send if its a tweet message
|
||||
return false if conversation.tweet?
|
||||
# Google Play replies are constrained — auto-greetings don't fit the one-shot review reply model
|
||||
return false if inbox.google_play?
|
||||
|
||||
first_message_from_contact? && inbox.greeting_enabled? && inbox.greeting_message.present?
|
||||
end
|
||||
|
||||
@@ -63,6 +63,12 @@ json.instagram_id resource.channel.try(:instagram_id) if resource.instagram?
|
||||
## Tiktok Attributes
|
||||
json.reauthorization_required resource.channel.try(:reauthorization_required?) if resource.tiktok?
|
||||
|
||||
## Google Play Attributes
|
||||
if resource.google_play?
|
||||
json.app_id resource.channel.try(:app_id)
|
||||
json.last_synced_at resource.channel.try(:last_synced_at)
|
||||
end
|
||||
|
||||
## Twilio Attributes
|
||||
json.messaging_service_sid resource.channel.try(:messaging_service_sid)
|
||||
json.phone_number resource.channel.try(:phone_number)
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
class AddLastSyncedAtToChannelGooglePlay < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
add_column :channel_google_play, :last_synced_at, :datetime
|
||||
end
|
||||
end
|
||||
@@ -4,10 +4,8 @@
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# content :text
|
||||
# content_fingerprint :string
|
||||
# external_link :string not null
|
||||
# last_sync_attempted_at :datetime
|
||||
# last_sync_error_code :string
|
||||
# last_synced_at :datetime
|
||||
# metadata :jsonb
|
||||
# name :string
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
#
|
||||
# Table name: companies
|
||||
#
|
||||
# id :bigint not null, primary key
|
||||
# additional_attributes :jsonb
|
||||
# contacts_count :integer default(0), not null
|
||||
# custom_attributes :jsonb
|
||||
# description :text
|
||||
# domain :string
|
||||
# last_activity_at :datetime
|
||||
# id :bigint not null, primary key
|
||||
# contacts_count :integer
|
||||
# description :text
|
||||
# domain :string
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
# name :string not null
|
||||
# created_at :datetime not null
|
||||
# updated_at :datetime not null
|
||||
# account_id :bigint not null
|
||||
#
|
||||
# Indexes
|
||||
#
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Google Play Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:url) { "/api/v1/accounts/#{account.id}/google_play/authorization" }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/google_play/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post url
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated agent' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
|
||||
it 'returns unauthorized' do
|
||||
post url, headers: agent.create_new_auth_token, as: :json
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
# Stand-in OAuth credentials so the authorize URL is built.
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_ID', value: 'client-id-123', locked: false)
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_SECRET', value: 'client-secret', locked: false)
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'returns a redirect URL with the androidpublisher scope' do
|
||||
post url,
|
||||
params: { app_id: 'com.example.app', inbox_name: 'My App' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
body = response.parsed_body
|
||||
expect(body['success']).to be true
|
||||
expect(body['url']).to include('client_id=client-id-123')
|
||||
expect(body['url']).to include('scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fandroidpublisher')
|
||||
expect(body['url']).to include('access_type=offline')
|
||||
expect(body['url']).to include('prompt=consent')
|
||||
expect(body['url']).to include('google_play%2Fcallback')
|
||||
end
|
||||
|
||||
it 'signs the state with the app_id and inbox_name so the callback can decode them' do
|
||||
post url,
|
||||
params: { app_id: 'com.example.app', inbox_name: 'My App' },
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
|
||||
state = CGI.parse(URI(response.parsed_body['url']).query)['state'].first
|
||||
decoded = Rails.application.message_verifier('google_play_oauth').verify(state).with_indifferent_access
|
||||
|
||||
expect(decoded[:account_id]).to eq(account.id)
|
||||
expect(decoded[:app_id]).to eq('com.example.app')
|
||||
expect(decoded[:inbox_name]).to eq('My App')
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,58 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'GooglePlay::CallbacksController', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
let(:code) { SecureRandom.hex(10) }
|
||||
let(:state_payload) { { account_id: account.id, app_id: 'com.example.app', inbox_name: 'My App' } }
|
||||
let(:state) { Rails.application.message_verifier('google_play_oauth').generate(state_payload, expires_in: 15.minutes) }
|
||||
let(:token_response) do
|
||||
{ access_token: 'play-access-token', refresh_token: 'play-refresh-token', token_type: 'Bearer', expires_in: 3599 }
|
||||
end
|
||||
|
||||
before do
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_ID', value: 'client-id-123', locked: false)
|
||||
create(:installation_config, name: 'GOOGLE_OAUTH_CLIENT_SECRET', value: 'client-secret', locked: false)
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
describe 'GET /google_play/callback' do
|
||||
it 'creates the channel + inbox and redirects to the agent assignment page' do
|
||||
stub_request(:post, 'https://oauth2.googleapis.com/o/oauth2/token')
|
||||
.to_return(status: 200, body: token_response.to_json, headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect do
|
||||
get '/google_play/callback', params: { code: code, state: state }
|
||||
end.to change(Channel::GooglePlay, :count).by(1)
|
||||
.and change(Inbox, :count).by(1)
|
||||
|
||||
inbox = Inbox.last
|
||||
channel = inbox.channel
|
||||
expect(channel.app_id).to eq('com.example.app')
|
||||
expect(channel.provider_config).to include('access_token' => 'play-access-token', 'refresh_token' => 'play-refresh-token')
|
||||
expect(inbox.name).to eq('My App')
|
||||
expect(response).to redirect_to(app_google_play_inbox_agents_url(account_id: account.id, inbox_id: inbox.id))
|
||||
end
|
||||
|
||||
it 'redirects to the inbox setup page surfacing the error when Google returns an error param' do
|
||||
get '/google_play/callback', params: { error: 'access_denied', state: state }
|
||||
|
||||
expect(Channel::GooglePlay.count).to eq(0)
|
||||
expect(response).to redirect_to(app_new_google_play_inbox_url(account_id: account.id, error: 'access_denied'))
|
||||
end
|
||||
|
||||
it 'redirects to the inbox setup page with the error when token exchange fails' do
|
||||
stub_request(:post, 'https://oauth2.googleapis.com/o/oauth2/token').to_return(status: 400, body: 'invalid')
|
||||
|
||||
get '/google_play/callback', params: { code: code, state: state }
|
||||
|
||||
expect(Channel::GooglePlay.count).to eq(0)
|
||||
expect(response.location).to include("/app/accounts/#{account.id}/settings/inboxes/new/google_play")
|
||||
expect(response.location).to include('error=')
|
||||
end
|
||||
|
||||
it 'falls back to / when the state cannot be decoded' do
|
||||
get '/google_play/callback', params: { code: code, state: 'tampered' }
|
||||
expect(response).to redirect_to('/')
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_google_play, class: 'Channel::GooglePlay' do
|
||||
account
|
||||
sequence(:app_id) { |n| "com.example.app#{n}" }
|
||||
provider_config do
|
||||
{
|
||||
'access_token' => SecureRandom.hex(16),
|
||||
'refresh_token' => SecureRandom.hex(16),
|
||||
'expires_on' => 1.hour.from_now.utc.to_s
|
||||
}
|
||||
end
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchGooglePlayReviewInboxesJob do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
before { clear_enqueued_jobs }
|
||||
|
||||
let!(:due_channel) { create(:channel_google_play, last_synced_at: 2.hours.ago) }
|
||||
let!(:fresh_channel) { create(:channel_google_play, last_synced_at: 5.minutes.ago) }
|
||||
let!(:never_synced_channel) { create(:channel_google_play, last_synced_at: nil) }
|
||||
|
||||
before { clear_enqueued_jobs } # also clear the after_create_commit fetches from factory
|
||||
|
||||
it 'enqueues a fetch job for channels whose sync interval has elapsed' do
|
||||
described_class.perform_now
|
||||
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).to have_been_enqueued.with(due_channel).once
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).to have_been_enqueued.with(never_synced_channel).once
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).not_to have_been_enqueued.with(fresh_channel)
|
||||
end
|
||||
|
||||
it 'skips inboxes belonging to suspended accounts' do
|
||||
due_channel.account.update!(status: :suspended)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(Inboxes::FetchGooglePlayReviewsJob).not_to have_been_enqueued.with(due_channel)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,39 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::FetchGooglePlayReviewsJob do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:review) do
|
||||
{
|
||||
'reviewId' => 'rev-1',
|
||||
'authorName' => 'Tester',
|
||||
'comments' => [{ 'userComment' => { 'text' => 'good', 'starRating' => 5, 'lastModified' => { 'seconds' => '1' } } }]
|
||||
}
|
||||
end
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:fetch_reviews).and_return([review])
|
||||
end
|
||||
|
||||
it 'invokes ReviewBuilder for each fetched review' do
|
||||
builder = instance_double(GooglePlay::ReviewBuilder, perform: true)
|
||||
allow(GooglePlay::ReviewBuilder).to receive(:new).with(review: review, channel: channel).and_return(builder)
|
||||
|
||||
described_class.perform_now(channel)
|
||||
|
||||
expect(builder).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'stamps last_synced_at after a successful pass' do
|
||||
travel_to Time.zone.parse('2026-05-19 12:00') do
|
||||
described_class.perform_now(channel)
|
||||
expect(channel.reload.last_synced_at).to be_within(1.second).of(Time.current)
|
||||
end
|
||||
end
|
||||
|
||||
it 'captures per-review errors without aborting the rest of the run' do
|
||||
allow(GooglePlay::ReviewBuilder).to receive(:new).and_raise(StandardError, 'boom')
|
||||
expect(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
expect { described_class.perform_now(channel) }.not_to raise_error
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,134 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Channel::GooglePlay do
|
||||
include ActiveJob::TestHelper
|
||||
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_google_play, account: account) }
|
||||
|
||||
describe 'validations' do
|
||||
it 'requires app_id' do
|
||||
record = described_class.new(account: account, provider_config: { 'access_token' => 'x' })
|
||||
expect(record).not_to be_valid
|
||||
expect(record.errors[:app_id]).to include("can't be blank")
|
||||
end
|
||||
|
||||
it 'requires app_id to be unique per account' do
|
||||
create(:channel_google_play, account: account, app_id: 'com.dup.app')
|
||||
duplicate = build(:channel_google_play, account: account, app_id: 'com.dup.app')
|
||||
expect(duplicate).not_to be_valid
|
||||
end
|
||||
|
||||
it 'allows the same app_id across different accounts' do
|
||||
create(:channel_google_play, account: account, app_id: 'com.shared.app')
|
||||
other = build(:channel_google_play, account: create(:account), app_id: 'com.shared.app')
|
||||
expect(other).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
describe '#name' do
|
||||
it 'returns the human-readable channel name' do
|
||||
expect(channel.name).to eq 'Google Play'
|
||||
end
|
||||
end
|
||||
|
||||
describe '#sync_due?' do
|
||||
it 'is true when last_synced_at is nil' do
|
||||
channel.update!(last_synced_at: nil)
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'is true when last_synced_at is older than the sync interval' do
|
||||
channel.update!(last_synced_at: 2.hours.ago)
|
||||
expect(channel.sync_due?).to be true
|
||||
end
|
||||
|
||||
it 'is false when last_synced_at is within the sync interval' do
|
||||
channel.update!(last_synced_at: 10.minutes.ago)
|
||||
expect(channel.sync_due?).to be false
|
||||
end
|
||||
end
|
||||
|
||||
describe '#fetch_reviews' do
|
||||
let(:base_url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews" }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(described_class).to receive(:access_token).and_return('test-token')
|
||||
end
|
||||
|
||||
it 'returns the reviews list for a single page' do
|
||||
stub_request(:get, base_url)
|
||||
.with(query: hash_including('maxResults' => '100'))
|
||||
.to_return(status: 200, body: { reviews: [{ 'reviewId' => 'r-1' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
expect(channel.fetch_reviews).to eq([{ 'reviewId' => 'r-1' }])
|
||||
end
|
||||
|
||||
it 'follows tokenPagination.nextPageToken across pages' do
|
||||
stub_request(:get, base_url)
|
||||
.with(query: hash_including('maxResults' => '100'))
|
||||
.to_return(
|
||||
{ status: 200,
|
||||
body: { reviews: [{ 'reviewId' => 'r-1' }], tokenPagination: { nextPageToken: 'PAGE2' } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' } },
|
||||
{ status: 200,
|
||||
body: { reviews: [{ 'reviewId' => 'r-2' }] }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' } }
|
||||
)
|
||||
|
||||
expect(channel.fetch_reviews.map { |r| r['reviewId'] }).to eq(%w[r-1 r-2])
|
||||
end
|
||||
|
||||
it 'raises when the API responds with an error' do
|
||||
stub_request(:get, base_url).to_return(status: 403, body: 'forbidden')
|
||||
|
||||
expect { channel.fetch_reviews }.to raise_error(/Google Play reviews fetch failed \(403\)/)
|
||||
end
|
||||
end
|
||||
|
||||
describe '#reply_to_review' do
|
||||
let(:url) { "#{described_class::API_BASE_URL}/applications/#{channel.app_id}/reviews/REV-1:reply" }
|
||||
|
||||
before do
|
||||
allow_any_instance_of(described_class).to receive(:access_token).and_return('test-token')
|
||||
end
|
||||
|
||||
it 'returns a source_id composed from the review id and lastEdited.seconds' do
|
||||
stub_request(:post, url).to_return(
|
||||
status: 200,
|
||||
body: { result: { replyText: 'thanks', lastEdited: { seconds: '1779000000', nanos: 0 } } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
expect(channel.reply_to_review('REV-1', 'thanks')).to eq('REV-1::reply::1779000000')
|
||||
end
|
||||
|
||||
it 'truncates the reply text to MAX_REPLY_LENGTH' do
|
||||
long_text = 'a' * 500
|
||||
stub_request(:post, url)
|
||||
.with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH))
|
||||
.to_return(status: 200, body: { result: { lastEdited: { seconds: '1' } } }.to_json,
|
||||
headers: { 'Content-Type' => 'application/json' })
|
||||
|
||||
channel.reply_to_review('REV-1', long_text)
|
||||
expect(WebMock).to have_requested(:post, url)
|
||||
.with(body: hash_including('replyText' => 'a' * described_class::MAX_REPLY_LENGTH))
|
||||
end
|
||||
|
||||
it 'raises when the API responds with an error' do
|
||||
stub_request(:post, url).to_return(status: 400, body: 'bad request')
|
||||
|
||||
expect { channel.reply_to_review('REV-1', 'x') }
|
||||
.to raise_error(/Google Play reply failed \(400\)/)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'after_create_commit' do
|
||||
it 'enqueues an initial review fetch' do
|
||||
expect do
|
||||
create(:channel_google_play, account: account)
|
||||
end.to have_enqueued_job(Inboxes::FetchGooglePlayReviewsJob)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -625,4 +625,22 @@ RSpec.describe Conversations::MessageWindowService do
|
||||
expect(service.can_reply?).to be true
|
||||
end
|
||||
end
|
||||
|
||||
describe 'on Google Play channels' do
|
||||
let!(:google_play_channel) { create(:channel_google_play) }
|
||||
let(:inbox) { google_play_channel.inbox }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: google_play_channel.account) }
|
||||
|
||||
it 'allows replies when the most recent incoming review is within 7 days' do
|
||||
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 3.days.ago)
|
||||
|
||||
expect(described_class.new(conversation).can_reply?).to be true
|
||||
end
|
||||
|
||||
it 'blocks replies when the most recent incoming review is older than 7 days' do
|
||||
create(:message, account: conversation.account, inbox: inbox, conversation: conversation, created_at: 10.days.ago)
|
||||
|
||||
expect(described_class.new(conversation).can_reply?).to be false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GooglePlay::ReviewBuilder do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
|
||||
def base_review(overrides = {})
|
||||
{
|
||||
'reviewId' => 'rev-abc',
|
||||
'authorName' => 'Jane Reviewer',
|
||||
'comments' => [
|
||||
{ 'userComment' => {
|
||||
'text' => 'Great app, but crashes sometimes.',
|
||||
'starRating' => 4,
|
||||
'reviewerLanguage' => 'en',
|
||||
'appVersionName' => '4.5.0',
|
||||
'androidOsVersion' => 33,
|
||||
'device' => 'a15',
|
||||
'deviceMetadata' => { 'productName' => 'Galaxy A15', 'manufacturer' => 'Samsung' },
|
||||
'lastModified' => { 'seconds' => '1779000000', 'nanos' => 0 }
|
||||
} }
|
||||
]
|
||||
}.deep_merge(overrides)
|
||||
end
|
||||
|
||||
it 'creates a contact, conversation, and incoming message from userComment' do
|
||||
expect do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
end.to change(Contact, :count).by(1)
|
||||
.and change(Conversation, :count).by(1)
|
||||
.and change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.message_type).to eq 'incoming'
|
||||
expect(message.source_id).to eq 'rev-abc::1779000000'
|
||||
expect(message.content).to include('★★★★☆ (4/5)')
|
||||
expect(message.content).to include('Great app, but crashes sometimes.')
|
||||
expect(message.content).to include('Galaxy A15 • v4.5.0 • Android 33')
|
||||
expect(message.content_attributes['google_play']).to include(
|
||||
'star_rating' => 4,
|
||||
'app_version' => '4.5.0',
|
||||
'manufacturer' => 'Samsung',
|
||||
'reviewer_language' => 'en'
|
||||
)
|
||||
end
|
||||
|
||||
it 'is idempotent for the same userComment lastModified seconds' do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
|
||||
expect do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'creates a new incoming message when the reviewer edits (new lastModified)' do
|
||||
described_class.new(review: base_review, channel: channel).perform
|
||||
|
||||
edited = base_review.deep_dup
|
||||
edited['comments'].first['userComment']['lastModified']['seconds'] = '1779999999'
|
||||
edited['comments'].first['userComment']['text'] = 'Edited review'
|
||||
|
||||
expect do
|
||||
described_class.new(review: edited, channel: channel).perform
|
||||
end.to change(Message, :count).by(1)
|
||||
.and(not_change(Conversation, :count))
|
||||
end
|
||||
|
||||
it 'skips entirely when the user comment text is blank' do
|
||||
review = base_review
|
||||
review['comments'].first['userComment']['text'] = ''
|
||||
|
||||
expect do
|
||||
described_class.new(review: review, channel: channel).perform
|
||||
end.to(not_change(Message, :count))
|
||||
end
|
||||
|
||||
context 'when the review also has a developerComment' do
|
||||
let(:review_with_reply) do
|
||||
base_review('comments' => [
|
||||
{ 'userComment' => base_review['comments'].first['userComment'] },
|
||||
{ 'developerComment' => {
|
||||
'text' => 'Thanks for the feedback!',
|
||||
'lastModified' => { 'seconds' => '1779100000' }
|
||||
} }
|
||||
])
|
||||
end
|
||||
|
||||
it 'mirrors the developer comment as an outgoing message' do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
|
||||
outgoing = Message.outgoing.last
|
||||
expect(outgoing.content).to eq('Thanks for the feedback!')
|
||||
expect(outgoing.source_id).to eq('rev-abc::reply::1779100000')
|
||||
expect(outgoing.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'is idempotent and does not duplicate the outgoing reply' do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
|
||||
expect do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
end.not_to change(Message, :count)
|
||||
end
|
||||
|
||||
it 'dedupes against a reply already sent through Chatwoot with the same source_id' do
|
||||
conversation = create(:conversation, inbox: inbox, account: channel.account,
|
||||
contact_inbox: create(:contact_inbox, inbox: inbox, source_id: 'rev-abc'))
|
||||
create(:message, conversation: conversation, account: channel.account, inbox: inbox,
|
||||
message_type: :outgoing, source_id: 'rev-abc::reply::1779100000', content: 'sent via chatwoot')
|
||||
|
||||
expect do
|
||||
described_class.new(review: review_with_reply, channel: channel).perform
|
||||
end.to change(Message, :count).by(1) # the incoming user message only
|
||||
expect(Message.outgoing.where(source_id: 'rev-abc::reply::1779100000').count).to eq(1)
|
||||
end
|
||||
end
|
||||
|
||||
# RSpec helper for `not_change` (no built-in opposite of `change`)
|
||||
matcher :not_change do |obj, method|
|
||||
supports_block_expectations
|
||||
match do |block|
|
||||
before = obj.send(method)
|
||||
block.call
|
||||
obj.send(method) == before
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,34 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe GooglePlay::SendOnGooglePlayService do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: channel.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: inbox, source_id: 'REV-1') }
|
||||
let(:conversation) { create(:conversation, contact: contact, contact_inbox: contact_inbox, inbox: inbox, account: channel.account) }
|
||||
let(:message) { create(:message, message_type: :outgoing, content: 'thanks', conversation: conversation, inbox: inbox, account: channel.account) }
|
||||
|
||||
describe '#perform' do
|
||||
it 'stamps the outgoing message with the source_id returned from the API' do
|
||||
allow(channel).to receive(:reply_to_review).with('REV-1', 'thanks').and_return('REV-1::reply::42')
|
||||
allow_any_instance_of(Inbox).to receive(:channel).and_return(channel)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.source_id).to eq('REV-1::reply::42')
|
||||
expect(message.status).to eq('sent')
|
||||
end
|
||||
|
||||
it 'marks the message as failed when the API call raises' do
|
||||
allow(channel).to receive(:reply_to_review).and_raise(StandardError, 'Google Play reply failed (403)')
|
||||
allow_any_instance_of(Inbox).to receive(:channel).and_return(channel)
|
||||
allow(ChatwootExceptionTracker).to receive(:new).and_call_original
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(message.reload.status).to eq('failed')
|
||||
expect(message.external_error).to eq('Google Play reply failed (403)')
|
||||
expect(ChatwootExceptionTracker).to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -270,4 +270,28 @@ describe MessageTemplates::HookExecutionService do
|
||||
expect(out_of_office_service).not_to receive(:perform)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when the inbox is a Google Play Reviews channel' do
|
||||
let(:channel) { create(:channel_google_play) }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:conversation) { create(:conversation, inbox: inbox, account: channel.account) }
|
||||
|
||||
it 'does not fire the greeting template even when greeting_enabled is true' do
|
||||
inbox.update!(greeting_enabled: true, greeting_message: 'Thanks for reviewing!')
|
||||
allow(MessageTemplates::Template::Greeting).to receive(:new)
|
||||
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::Greeting).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does not fire the out-of-office template even when configured' do
|
||||
inbox.update!(working_hours_enabled: true, out_of_office_message: 'Back tomorrow')
|
||||
allow(MessageTemplates::Template::OutOfOffice).to receive(:new)
|
||||
|
||||
create(:message, conversation: conversation, account: conversation.account)
|
||||
|
||||
expect(MessageTemplates::Template::OutOfOffice).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user