feat: TikTok channel (#12741)
fixes: #11834 This pull request introduces TikTok channel integration, enabling users to connect and manage TikTok business accounts similarly to other supported social channels. The changes span backend API endpoints, authentication helpers, webhook handling, configuration, and frontend components to support TikTok as a first-class channel. **Key Notes** * This integration is only compatible with TikTok Business Accounts * Special permissions are required to access the TikTok [Business Messaging API](https://business-api.tiktok.com/portal/docs?id=1832183871604753). * The Business Messaging API is region-restricted and is currently unavailable to users in the EU. * Only TEXT, IMAGE, and POST_SHARE messages are currently supported due to limitations in the TikTok Business Messaging API * A message will be successfully sent only if it contains text alone or one image attachment. Messages with multiple attachments or those combining text and attachments will fail and receive a descriptive error status. * Messages sent directly from the TikTok App will be synced into the system * Initiating a new conversation from the system is not permitted due to limitations from the TikTok Business Messaging API. **Backend: TikTok Channel Integration** * Added `Api::V1::Accounts::Tiktok::AuthorizationsController` to handle TikTok OAuth authorization initiation, returning the TikTok authorization URL. * Implemented `Tiktok::CallbacksController` to handle TikTok OAuth callback, process authorization results, create or update channel/inbox, and handle errors or denied scopes. * Added `Webhooks::TiktokController` to receive and verify TikTok webhook events, including signature verification and event dispatching. * Created `Tiktok::IntegrationHelper` module for JWT-based token generation and verification for secure TikTok OAuth state management. **Configuration and Feature Flags** * Added TikTok app credentials (`TIKTOK_APP_ID`, `TIKTOK_APP_SECRET`) to allowed configs and app config, and registered TikTok as a feature in the super admin features YAML. [[1]](diffhunk://#diff-5e46e1d248631a1147521477d84a54f8ba6846ea21c61eca5f70042d960467f4R43) [[2]](diffhunk://#diff-8bf37a019cab1dedea458c437bd93e34af1d6e22b1672b1d43ef6eaa4dcb7732R69) [[3]](diffhunk://#diff-123164bea29f3c096b0d018702b090d5ae670760c729141bd4169a36f5f5c1caR74-R79) **Frontend: TikTok Channel UI and Messaging Support** * Added `TiktokChannel` API client for frontend TikTok authorization requests. * Updated channel icon mappings and tests to include TikTok (`Channel::Tiktok`). [[1]](diffhunk://#diff-b852739ed45def61218d581d0de1ba73f213f55570aa5eec52aaa08f380d0e16R16) [[2]](diffhunk://#diff-3cd3ae32e94ef85f1f2c4435abf0775cc0614fb37ee25d97945cd51573ef199eR64-R69) * Enabled TikTok as a supported channel in contact forms, channel widgets, and feature toggles. [[1]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R47) [[2]](diffhunk://#diff-ec59c85e1403aaed1a7de35971fe16b7033d5cd763be590903ebf8f1ca25a010R69) [[3]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R26-R29) [[4]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R51-R54) [[5]](diffhunk://#diff-725b90ca7e3a6837ec8291e9f57094f6a46b3ee00e598d16564f77f32cf354b0R68) * Updated message meta logic to support TikTok-specific message statuses (sent, delivered, read). [[1]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696R23) [[2]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L63-R65) [[3]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L81-R84) [[4]](diffhunk://#diff-e41239cf8dda36c1bd1066dbb17588ae8868e56289072c74b3a6d7ef5abdd696L103-R107) * Added support for embedded message attachments (e.g., TikTok embeds) with a new `EmbedBubble` component and updated message rendering logic. [[1]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR31) [[2]](diffhunk://#diff-047859f9368a46d6d20177df7d6d623768488ecc38a5b1e284f958fad49add68R1-R19) [[3]](diffhunk://#diff-c3d701caf27d9c31e200c6143c11a11b9d8826f78aa2ce5aa107470e6fdb9d7fR316) [[4]](diffhunk://#diff-cbc85e7c4c8d56f2a847d0b01cd48ef36e5f87b43023bff0520fdfc707283085R52) * Adjusted reply policy and UI messaging for TikTok's 48-hour reply window. [[1]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R208-R210) [[2]](diffhunk://#diff-0d691f6a983bd89502f91253ecf22e871314545d1e3d3b106fbfc76bf6d8e1c7R224-R226) These changes collectively enable end-to-end TikTok channel support, from configuration and OAuth flow to webhook processing and frontend message handling. ------------ # TikTok App Setup & Configuration 1. Grant access to the Business Messaging API ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832184145137922)) 2. Set the app authorization redirect URL to `https://FRONTEND_URL/tiktok/callback` 3. Update the installation config with TikTok App ID and Secret 4. Create a Business Messaging Webhook configuration and set the callback url to `https://FRONTEND_URL/webhooks/tiktok` ([Documentation](https://business-api.tiktok.com/portal/docs?id=1832190670631937)) . You can do this by calling `Tiktok::AuthClient.update_webhook_callback` from rails console once you finish Tiktok channel configuration in super admin ( will be automated in future ) 5. Enable TikTok channel feature in an account --------- Co-authored-by: Sojan Jose <sojan@pepalo.com> Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'TikTok Authorization API', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
describe 'POST /api/v1/accounts/{account.id}/tiktok/authorization' do
|
||||
context 'when it is an unauthenticated user' do
|
||||
it 'returns unauthorized' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization"
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when it is an authenticated user' do
|
||||
let(:agent) { create(:user, account: account, role: :agent) }
|
||||
let(:administrator) { create(:user, account: account, role: :administrator) }
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: %w[TIKTOK_APP_ID TIKTOK_APP_SECRET]).delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'returns unauthorized for agent' do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: agent.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
|
||||
it 'creates a new authorization and returns the redirect url' do
|
||||
with_modified_env TIKTOK_APP_ID: 'tiktok-app-id', TIKTOK_APP_SECRET: 'tiktok-app-secret' do
|
||||
post "/api/v1/accounts/#{account.id}/tiktok/authorization",
|
||||
headers: administrator.create_new_auth_token,
|
||||
as: :json
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(response.parsed_body['success']).to be true
|
||||
|
||||
helper = Class.new do
|
||||
include Tiktok::IntegrationHelper
|
||||
end.new
|
||||
|
||||
expected_state = helper.generate_tiktok_token(account.id)
|
||||
expected_url = Tiktok::AuthClient.authorize_url(state: expected_state)
|
||||
|
||||
expect(response.parsed_body['url']).to eq(expected_url)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
139
spec/controllers/tiktok/callbacks_controller_spec.rb
Normal file
139
spec/controllers/tiktok/callbacks_controller_spec.rb
Normal file
@@ -0,0 +1,139 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'TikTok Callbacks', type: :request do
|
||||
let(:account) { create(:account) }
|
||||
|
||||
let(:client_secret) { 'tiktok-app-secret' }
|
||||
let(:client_id) { 'tiktok-app-id' }
|
||||
|
||||
let(:token_endpoint) { 'https://business-api.tiktok.com/open_api/v1.3/tt_user/oauth2/token/' }
|
||||
let(:business_endpoint) { 'https://business-api.tiktok.com/open_api/v1.3/business/get/' }
|
||||
|
||||
let(:tiktok_access_token) { 'access-token-1' }
|
||||
let(:tiktok_refresh_token) { 'refresh-token-1' }
|
||||
let(:tiktok_business_id) { 'biz-123' }
|
||||
|
||||
let(:token_response) do
|
||||
{
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
open_id: tiktok_business_id,
|
||||
scope: Tiktok::AuthClient::REQUIRED_SCOPES.join(','),
|
||||
access_token: tiktok_access_token,
|
||||
refresh_token: tiktok_refresh_token,
|
||||
expires_in: 86_400,
|
||||
refresh_token_expires_in: 2_592_000
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:business_response) do
|
||||
{
|
||||
code: 0,
|
||||
message: 'ok',
|
||||
data: {
|
||||
username: 'tiktok_user',
|
||||
display_name: 'TikTok Display Name',
|
||||
profile_image: 'https://www.example.com/avatar.png'
|
||||
}
|
||||
}.to_json
|
||||
end
|
||||
|
||||
let(:state) do
|
||||
JWT.encode({ sub: account.id, iat: Time.current.to_i }, client_secret, 'HS256')
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: %w[TIKTOK_APP_ID TIKTOK_APP_SECRET]).delete_all
|
||||
GlobalConfig.clear_cache
|
||||
|
||||
stub_request(:post, token_endpoint).to_return(status: 200, body: token_response, headers: { 'Content-Type' => 'application/json' })
|
||||
stub_request(:get, business_endpoint)
|
||||
.with(query: hash_including('business_id' => tiktok_business_id))
|
||||
.to_return(status: 200, body: business_response, headers: { 'Content-Type' => 'application/json' })
|
||||
end
|
||||
|
||||
it 'creates channel and inbox and redirects to agents step for new connections' do
|
||||
expect(Avatar::AvatarFromUrlJob).to receive(:perform_later).with(instance_of(Inbox), 'https://www.example.com/avatar.png')
|
||||
|
||||
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
|
||||
expect do
|
||||
get '/tiktok/callback', params: { code: 'valid_code', state: state }
|
||||
end.to change(Channel::Tiktok, :count).by(1).and change(Inbox, :count).by(1)
|
||||
end
|
||||
|
||||
inbox = Inbox.last
|
||||
channel = inbox.channel
|
||||
|
||||
expect(channel.business_id).to eq(tiktok_business_id)
|
||||
expect(channel.access_token).to eq(tiktok_access_token)
|
||||
expect(channel.refresh_token).to eq(tiktok_refresh_token)
|
||||
|
||||
expect(response).to redirect_to(app_tiktok_inbox_agents_url(account_id: account.id, inbox_id: inbox.id))
|
||||
end
|
||||
|
||||
it 'updates an existing channel and redirects to settings' do
|
||||
existing_channel = create(
|
||||
:channel_tiktok,
|
||||
account: account,
|
||||
business_id: tiktok_business_id,
|
||||
access_token: 'old-access-token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
expires_at: 1.hour.ago,
|
||||
refresh_token_expires_at: 1.day.from_now
|
||||
)
|
||||
existing_channel.inbox.update!(name: 'Old Name')
|
||||
|
||||
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
|
||||
expect do
|
||||
get '/tiktok/callback', params: { code: 'valid_code', state: state }
|
||||
end.to not_change(Channel::Tiktok, :count).and not_change(Inbox, :count)
|
||||
end
|
||||
|
||||
existing_channel.reload
|
||||
inbox = existing_channel.inbox.reload
|
||||
|
||||
expect(existing_channel.access_token).to eq(tiktok_access_token)
|
||||
expect(existing_channel.refresh_token).to eq(tiktok_refresh_token)
|
||||
expect(inbox.name).to eq('TikTok Display Name')
|
||||
|
||||
expect(response).to redirect_to(app_tiktok_inbox_settings_url(account_id: account.id, inbox_id: inbox.id))
|
||||
end
|
||||
|
||||
it 'redirects to error page when user denies authorization' do
|
||||
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
|
||||
get '/tiktok/callback', params: { error: 'access_denied', error_description: 'User cancelled', error_code: '400', state: state }
|
||||
end
|
||||
|
||||
expect(response).to redirect_to(
|
||||
app_new_tiktok_inbox_url(
|
||||
account_id: account.id,
|
||||
error_type: 'access_denied',
|
||||
code: '400',
|
||||
error_message: 'User cancelled'
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
it 'redirects to error page when required scopes are not granted' do
|
||||
stub_request(:post, token_endpoint).to_return(
|
||||
status: 200,
|
||||
body: JSON.parse(token_response).deep_merge('data' => { 'scope' => 'user.info.basic' }).to_json,
|
||||
headers: { 'Content-Type' => 'application/json' }
|
||||
)
|
||||
|
||||
with_modified_env TIKTOK_APP_ID: client_id, TIKTOK_APP_SECRET: client_secret do
|
||||
get '/tiktok/callback', params: { code: 'valid_code', state: state }
|
||||
end
|
||||
|
||||
expect(response).to redirect_to(
|
||||
app_new_tiktok_inbox_url(
|
||||
account_id: account.id,
|
||||
error_type: 'ungranted_scopes',
|
||||
code: 400,
|
||||
error_message: 'User did not grant all the required scopes'
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
64
spec/controllers/webhooks/tiktok_controller_spec.rb
Normal file
64
spec/controllers/webhooks/tiktok_controller_spec.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Webhooks::TiktokController', type: :request do
|
||||
let(:client_secret) { 'test-tiktok-secret' }
|
||||
let(:timestamp) { Time.current.to_i }
|
||||
let(:event_payload) do
|
||||
{
|
||||
event: 'im_receive_msg',
|
||||
user_openid: 'biz-123',
|
||||
content: { conversation_id: 'tt-conv-1' }.to_json
|
||||
}
|
||||
end
|
||||
|
||||
def signature_for(body)
|
||||
OpenSSL::HMAC.hexdigest('SHA256', client_secret, "#{timestamp}.#{body}")
|
||||
end
|
||||
|
||||
before do
|
||||
InstallationConfig.where(name: 'TIKTOK_APP_SECRET').delete_all
|
||||
GlobalConfig.clear_cache
|
||||
end
|
||||
|
||||
it 'enqueues the events job for valid signature' do
|
||||
allow(Webhooks::TiktokEventsJob).to receive(:perform_later)
|
||||
|
||||
body = event_payload.to_json
|
||||
with_modified_env TIKTOK_APP_SECRET: client_secret do
|
||||
post '/webhooks/tiktok',
|
||||
params: body,
|
||||
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{signature_for(body)}" }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Webhooks::TiktokEventsJob).to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'delays processing by 2 seconds for echo events' do
|
||||
job_double = class_double(Webhooks::TiktokEventsJob)
|
||||
allow(Webhooks::TiktokEventsJob).to receive(:set).with(wait: 2.seconds).and_return(job_double)
|
||||
allow(job_double).to receive(:perform_later)
|
||||
|
||||
body = event_payload.to_json
|
||||
with_modified_env TIKTOK_APP_SECRET: client_secret do
|
||||
post '/webhooks/tiktok?event=im_send_msg',
|
||||
params: body,
|
||||
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{signature_for(body)}" }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
expect(Webhooks::TiktokEventsJob).to have_received(:set).with(wait: 2.seconds)
|
||||
expect(job_double).to have_received(:perform_later)
|
||||
end
|
||||
|
||||
it 'returns unauthorized for invalid signature' do
|
||||
body = event_payload.to_json
|
||||
with_modified_env TIKTOK_APP_SECRET: client_secret do
|
||||
post '/webhooks/tiktok',
|
||||
params: body,
|
||||
headers: { 'CONTENT_TYPE' => 'application/json', 'Tiktok-Signature' => "t=#{timestamp},s=#{'0' * 64}" }
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
16
spec/factories/channel/channel_tiktok.rb
Normal file
16
spec/factories/channel/channel_tiktok.rb
Normal file
@@ -0,0 +1,16 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :channel_tiktok, class: 'Channel::Tiktok' do
|
||||
account
|
||||
business_id { SecureRandom.hex(16) }
|
||||
access_token { SecureRandom.hex(32) }
|
||||
refresh_token { SecureRandom.hex(32) }
|
||||
expires_at { 1.day.from_now }
|
||||
refresh_token_expires_at { 30.days.from_now }
|
||||
|
||||
after(:create) do |channel|
|
||||
create(:inbox, channel: channel, account: channel.account)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -135,5 +135,14 @@ RSpec.describe SendReplyJob do
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
end
|
||||
|
||||
it 'calls ::Tiktok::SendOnTiktokService when its tiktok message' do
|
||||
tiktok_channel = create(:channel_tiktok)
|
||||
message = create(:message, conversation: create(:conversation, inbox: tiktok_channel.inbox))
|
||||
allow(Tiktok::SendOnTiktokService).to receive(:new).with(message: message).and_return(process_service)
|
||||
expect(Tiktok::SendOnTiktokService).to receive(:new).with(message: message)
|
||||
expect(process_service).to receive(:perform)
|
||||
described_class.perform_now(message.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
91
spec/jobs/webhooks/tiktok_events_job_spec.rb
Normal file
91
spec/jobs/webhooks/tiktok_events_job_spec.rb
Normal file
@@ -0,0 +1,91 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Webhooks::TiktokEventsJob do
|
||||
let(:account) { create(:account) }
|
||||
let!(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
|
||||
let(:job) { described_class.new }
|
||||
|
||||
before do
|
||||
allow(job).to receive(:with_lock).and_yield
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'processes im_receive_msg events via Tiktok::MessageService' do
|
||||
message_service = instance_double(Tiktok::MessageService, perform: true)
|
||||
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
|
||||
|
||||
event = {
|
||||
event: 'im_receive_msg',
|
||||
user_openid: 'biz-123',
|
||||
content: { conversation_id: 'tt-conv-1' }.to_json
|
||||
}
|
||||
|
||||
job.perform(event)
|
||||
|
||||
expect(Tiktok::MessageService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
|
||||
expect(message_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'processes im_mark_read_msg events via Tiktok::ReadStatusService' do
|
||||
read_status_service = instance_double(Tiktok::ReadStatusService, perform: true)
|
||||
allow(Tiktok::ReadStatusService).to receive(:new).and_return(read_status_service)
|
||||
|
||||
event = {
|
||||
event: 'im_mark_read_msg',
|
||||
user_openid: 'biz-123',
|
||||
content: { conversation_id: 'tt-conv-1', read: { last_read_timestamp: 1_700_000_000_000 }, from_user: { id: 'user-1' } }.to_json
|
||||
}
|
||||
|
||||
job.perform(event)
|
||||
|
||||
expect(Tiktok::ReadStatusService).to have_received(:new).with(channel: channel, content: hash_including(conversation_id: 'tt-conv-1'))
|
||||
expect(read_status_service).to have_received(:perform)
|
||||
end
|
||||
|
||||
it 'ignores unsupported event types' do
|
||||
allow(Tiktok::MessageService).to receive(:new)
|
||||
|
||||
event = {
|
||||
event: 'unknown_event',
|
||||
user_openid: 'biz-123',
|
||||
content: { conversation_id: 'tt-conv-1' }.to_json
|
||||
}
|
||||
|
||||
job.perform(event)
|
||||
|
||||
expect(Tiktok::MessageService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does nothing when channel is missing' do
|
||||
allow(Tiktok::MessageService).to receive(:new)
|
||||
|
||||
event = {
|
||||
event: 'im_receive_msg',
|
||||
user_openid: 'biz-does-not-exist',
|
||||
content: { conversation_id: 'tt-conv-1' }.to_json
|
||||
}
|
||||
|
||||
job.perform(event)
|
||||
|
||||
expect(Tiktok::MessageService).not_to have_received(:new)
|
||||
end
|
||||
|
||||
it 'does nothing when account is inactive' do
|
||||
allow(Channel::Tiktok).to receive(:find_by).and_return(channel)
|
||||
allow(channel.account).to receive(:active?).and_return(false)
|
||||
|
||||
message_service = instance_double(Tiktok::MessageService, perform: true)
|
||||
allow(Tiktok::MessageService).to receive(:new).and_return(message_service)
|
||||
|
||||
event = {
|
||||
event: 'im_receive_msg',
|
||||
user_openid: 'biz-123',
|
||||
content: { conversation_id: 'tt-conv-1' }.to_json
|
||||
}
|
||||
|
||||
job.perform(event)
|
||||
|
||||
expect(Tiktok::MessageService).not_to have_received(:new)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -155,6 +155,14 @@ RSpec.describe Attachment do
|
||||
end
|
||||
end
|
||||
|
||||
describe 'push_event_data for embed attachments' do
|
||||
it 'returns external url as data_url' do
|
||||
attachment = message.attachments.create!(account_id: message.account_id, file_type: :embed, external_url: 'https://example.com/embed')
|
||||
|
||||
expect(attachment.push_event_data[:data_url]).to eq('https://example.com/embed')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'file size validation' do
|
||||
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
|
||||
|
||||
|
||||
117
spec/services/tiktok/message_service_spec.rb
Normal file
117
spec/services/tiktok/message_service_spec.rb
Normal file
@@ -0,0 +1,117 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::MessageService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
|
||||
describe '#perform' do
|
||||
it 'creates an incoming text message' do
|
||||
content = {
|
||||
type: 'text',
|
||||
message_id: 'tt-msg-1',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
text: { body: 'Hello from TikTok' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
expect do
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
end.to change(Message, :count).by(1)
|
||||
|
||||
message = Message.last
|
||||
expect(message.inbox).to eq(inbox)
|
||||
expect(message.message_type).to eq('incoming')
|
||||
expect(message.content).to eq('Hello from TikTok')
|
||||
expect(message.source_id).to eq('tt-msg-1')
|
||||
expect(message.sender).to eq(contact)
|
||||
expect(message.content_attributes['is_unsupported']).to be_nil
|
||||
end
|
||||
|
||||
it 'creates an incoming unsupported message for non-supported types' do
|
||||
content = {
|
||||
type: 'sticker',
|
||||
message_id: 'tt-msg-2',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.content).to be_nil
|
||||
expect(message.content_attributes['is_unsupported']).to be true
|
||||
end
|
||||
|
||||
it 'creates an incoming embed attachment for share_post messages' do
|
||||
content = {
|
||||
type: 'share_post',
|
||||
message_id: 'tt-msg-3',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
share_post: { embed_url: 'https://www.tiktok.com/embed/123' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
attachment = message.attachments.last
|
||||
expect(attachment.file_type).to eq('embed')
|
||||
expect(attachment.external_url).to eq('https://www.tiktok.com/embed/123')
|
||||
end
|
||||
|
||||
it 'creates an incoming image attachment when media is present' do
|
||||
content = {
|
||||
type: 'image',
|
||||
message_id: 'tt-msg-4',
|
||||
timestamp: 1_700_000_000_000,
|
||||
conversation_id: 'tt-conv-1',
|
||||
image: { media_id: 'media-1' },
|
||||
from: 'Alice',
|
||||
from_user: { id: 'user-1' },
|
||||
to: 'Biz',
|
||||
to_user: { id: 'biz-123' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
tempfile = Tempfile.new(['tiktok', '.png'])
|
||||
tempfile.write('fake-image')
|
||||
tempfile.rewind
|
||||
tempfile.define_singleton_method(:original_filename) { 'tiktok.png' }
|
||||
tempfile.define_singleton_method(:content_type) { 'image/png' }
|
||||
|
||||
service = described_class.new(channel: channel, content: content)
|
||||
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
|
||||
allow(service).to receive(:fetch_attachment).and_return(tempfile)
|
||||
|
||||
service.perform
|
||||
|
||||
message = Message.last
|
||||
expect(message.attachments.count).to eq(1)
|
||||
expect(message.attachments.last.file_type).to eq('image')
|
||||
expect(message.attachments.last.file).to be_attached
|
||||
ensure
|
||||
tempfile.close!
|
||||
end
|
||||
end
|
||||
end
|
||||
35
spec/services/tiktok/read_status_service_spec.rb
Normal file
35
spec/services/tiktok/read_status_service_spec.rb
Normal file
@@ -0,0 +1,35 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::ReadStatusService do
|
||||
let(:account) { create(:account) }
|
||||
let(:channel) { create(:channel_tiktok, account: account, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let!(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
account: account,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { conversation_id: 'tt-conv-1' }
|
||||
)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'enqueues Conversations::UpdateMessageStatusJob for inbound read events' do
|
||||
allow(Conversations::UpdateMessageStatusJob).to receive(:perform_later)
|
||||
|
||||
content = {
|
||||
conversation_id: 'tt-conv-1',
|
||||
read: { last_read_timestamp: 1_700_000_000_000 },
|
||||
from_user: { id: 'user-1' }
|
||||
}.deep_symbolize_keys
|
||||
|
||||
described_class.new(channel: channel, content: content).perform
|
||||
|
||||
expect(Conversations::UpdateMessageStatusJob).to have_received(:perform_later).with(conversation.id, kind_of(Time))
|
||||
end
|
||||
end
|
||||
end
|
||||
71
spec/services/tiktok/send_on_tiktok_service_spec.rb
Normal file
71
spec/services/tiktok/send_on_tiktok_service_spec.rb
Normal file
@@ -0,0 +1,71 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::SendOnTiktokService do
|
||||
let(:tiktok_client) { instance_double(Tiktok::Client) }
|
||||
let(:status_update_service) { instance_double(Messages::StatusUpdateService, perform: true) }
|
||||
|
||||
let(:channel) { create(:channel_tiktok, business_id: 'biz-123') }
|
||||
let(:inbox) { channel.inbox }
|
||||
let(:contact) { create(:contact, account: inbox.account) }
|
||||
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
|
||||
let(:conversation) do
|
||||
create(
|
||||
:conversation,
|
||||
inbox: inbox,
|
||||
contact: contact,
|
||||
contact_inbox: contact_inbox,
|
||||
additional_attributes: { conversation_id: 'tt-conv-1' }
|
||||
)
|
||||
end
|
||||
|
||||
before do
|
||||
allow(channel).to receive(:validated_access_token).and_return('valid-access-token')
|
||||
allow(Tiktok::Client).to receive(:new).and_return(tiktok_client)
|
||||
allow(Messages::StatusUpdateService).to receive(:new).and_return(status_update_service)
|
||||
end
|
||||
|
||||
describe '#perform' do
|
||||
it 'sends outgoing text message and updates source_id' do
|
||||
allow(tiktok_client).to receive(:send_text_message).and_return('tt-msg-123')
|
||||
|
||||
message = create(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: 'Hello')
|
||||
message.update!(source_id: nil)
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_text_message).with('tt-conv-1', 'Hello', referenced_message_id: nil)
|
||||
expect(message.reload.source_id).to eq('tt-msg-123')
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'delivered')
|
||||
end
|
||||
|
||||
it 'sends outgoing image message when a single attachment is present' do
|
||||
allow(tiktok_client).to receive(:send_media_message).and_return('tt-msg-124')
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
attachment = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(tiktok_client).to have_received(:send_media_message).with('tt-conv-1', message.attachments.first, referenced_message_id: nil)
|
||||
expect(message.reload.source_id).to eq('tt-msg-124')
|
||||
end
|
||||
|
||||
it 'marks message as failed when sending multiple attachments' do
|
||||
allow(tiktok_client).to receive(:send_media_message)
|
||||
|
||||
message = build(:message, message_type: :outgoing, inbox: inbox, conversation: conversation, account: inbox.account, content: nil)
|
||||
a1 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
a1.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
a2 = message.attachments.new(account_id: message.account_id, file_type: :image)
|
||||
a2.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
|
||||
message.save!
|
||||
|
||||
described_class.new(message: message).perform
|
||||
|
||||
expect(Messages::StatusUpdateService).to have_received(:new).with(message, 'failed', kind_of(String))
|
||||
expect(tiktok_client).not_to have_received(:send_media_message)
|
||||
end
|
||||
end
|
||||
end
|
||||
57
spec/services/tiktok/token_service_spec.rb
Normal file
57
spec/services/tiktok/token_service_spec.rb
Normal file
@@ -0,0 +1,57 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Tiktok::TokenService do
|
||||
let(:channel) do
|
||||
create(
|
||||
:channel_tiktok,
|
||||
access_token: 'old-access-token',
|
||||
refresh_token: 'old-refresh-token',
|
||||
expires_at: 1.minute.ago,
|
||||
refresh_token_expires_at: 1.day.from_now
|
||||
)
|
||||
end
|
||||
|
||||
describe '#access_token' do
|
||||
it 'returns current token when it is still valid' do
|
||||
channel.update!(expires_at: 10.minutes.from_now)
|
||||
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('old-access-token')
|
||||
expect(Tiktok::AuthClient).not_to have_received(:renew_short_term_access_token)
|
||||
end
|
||||
|
||||
it 'refreshes access token when expired and refresh token is valid' do
|
||||
lock_manager = instance_double(Redis::LockManager, lock: true, unlock: true)
|
||||
allow(Redis::LockManager).to receive(:new).and_return(lock_manager)
|
||||
|
||||
allow(Tiktok::AuthClient).to receive(:renew_short_term_access_token).and_return(
|
||||
{
|
||||
access_token: 'new-access-token',
|
||||
refresh_token: 'new-refresh-token',
|
||||
expires_at: 1.day.from_now,
|
||||
refresh_token_expires_at: 30.days.from_now
|
||||
}.with_indifferent_access
|
||||
)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('new-access-token')
|
||||
expect(channel.reload.access_token).to eq('new-access-token')
|
||||
expect(channel.refresh_token).to eq('new-refresh-token')
|
||||
end
|
||||
|
||||
it 'prompts reauthorization when both access and refresh tokens are expired' do
|
||||
channel.update!(expires_at: 1.day.ago, refresh_token_expires_at: 1.minute.ago)
|
||||
|
||||
allow(channel).to receive(:reauthorization_required?).and_return(false)
|
||||
allow(channel).to receive(:prompt_reauthorization!)
|
||||
|
||||
token = described_class.new(channel: channel).access_token
|
||||
|
||||
expect(token).to eq('old-access-token')
|
||||
expect(channel).to have_received(:prompt_reauthorization!)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user