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:
Mazen Khalil
2025-12-17 18:54:50 +03:00
committed by GitHub
parent 116ed54c7e
commit ca5e112a8c
84 changed files with 2189 additions and 96 deletions

View File

@@ -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

View 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

View 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

View 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

View File

@@ -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

View 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

View File

@@ -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) }

View 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

View 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

View 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

View 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