Files
leadchat/spec/models/attachment_spec.rb
Mazen Khalil ca5e112a8c 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>
2025-12-17 07:54:50 -08:00

201 lines
7.4 KiB
Ruby

require 'rails_helper'
RSpec.describe Attachment do
let!(:message) { create(:message) }
describe 'external url validations' do
let(:attachment) { message.attachments.new(account_id: message.account_id, file_type: :image) }
before do
attachment.file.attach(io: Rails.root.join('spec/assets/avatar.png').open, filename: 'avatar.png', content_type: 'image/png')
end
context 'when it validates external url length' do
it 'valid when within limit' do
attachment.external_url = 'a' * Limits::URL_LENGTH_LIMIT
expect(attachment.valid?).to be true
end
it 'invalid when crossed the limit' do
attachment.external_url = 'a' * (Limits::URL_LENGTH_LIMIT + 5)
attachment.valid?
expect(attachment.errors[:external_url]).to include("is too long (maximum is #{Limits::URL_LENGTH_LIMIT} characters)")
end
end
end
describe 'download_url' do
it 'returns valid download url' do
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')
expect(attachment.download_url).not_to be_nil
end
end
describe 'with_attached_file?' do
it 'returns true if its an attachment with file' do
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')
expect(attachment.with_attached_file?).to be true
end
it 'returns false if its an attachment with out a file' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :fallback)
expect(attachment.with_attached_file?).to be false
end
end
describe 'push_event_data for instagram story mentions' do
let(:instagram_message) { create(:message, :instagram_story_mention) }
before do
# stubbing the request to facebook api during the message creation
stub_request(:get, %r{https://graph.facebook.com/.*}).to_return(status: 200, body: {
story: { mention: { link: 'http://graph.facebook.com/test-story-mention', id: '17920786367196703' } },
from: { username: 'Sender-id-1', id: 'Sender-id-1' },
id: 'instagram-message-id-1234'
}.to_json, headers: {})
end
it 'returns external url as data and thumb urls when message is incoming' do
external_url = instagram_message.attachments.first.external_url
expect(instagram_message.attachments.first.push_event_data[:data_url]).to eq external_url
end
it 'returns original attachment url as data url if the message is outgoing' do
message = create(:message, :instagram_story_mention, message_type: :outgoing)
expect(message.attachments.first.push_event_data[:data_url]).not_to eq message.attachments.first.external_url
end
end
describe 'thumb_url' do
it 'returns empty string for non-image attachments' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :file)
attachment.file.attach(io: StringIO.new('fake pdf'), filename: 'test.pdf', content_type: 'application/pdf')
expect(attachment.thumb_url).to eq('')
end
it 'generates thumb_url for image attachments' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
expect(attachment.thumb_url).to be_present
end
it 'handles unrepresentable images gracefully' do
attachment = message.attachments.create!(account_id: message.account_id, file_type: :image)
attachment.file.attach(io: StringIO.new('fake image'), filename: 'test.jpg', content_type: 'image/jpeg')
allow(attachment.file).to receive(:representation).and_raise(ActiveStorage::UnrepresentableError.new('Cannot represent'))
expect(Rails.logger).to receive(:warn).with(/Unrepresentable image attachment: #{attachment.id}/)
expect(attachment.thumb_url).to eq('')
end
end
describe 'meta data handling' do
let(:message) { create(:message) }
context 'when attachment is a contact type' do
let(:contact_attachment) do
message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890',
meta: {
first_name: 'John',
last_name: 'Doe'
}
)
end
it 'stores and retrieves meta data correctly' do
expect(contact_attachment.meta['first_name']).to eq('John')
expect(contact_attachment.meta['last_name']).to eq('Doe')
end
it 'includes meta data in push_event_data' do
event_data = contact_attachment.push_event_data
expect(event_data[:meta]).to eq({
'first_name' => 'John',
'last_name' => 'Doe'
})
end
it 'returns empty hash for meta if not set' do
attachment = message.attachments.create!(
account_id: message.account_id,
file_type: :contact,
fallback_title: '+1234567890'
)
expect(attachment.push_event_data[:meta]).to eq({})
end
end
context 'when meta is used with other file types' do
let(:image_attachment) do
attachment = message.attachments.new(
account_id: message.account_id,
file_type: :image,
meta: { description: 'Test image' }
)
attachment.file.attach(
io: Rails.root.join('spec/assets/avatar.png').open,
filename: 'avatar.png',
content_type: 'image/png'
)
attachment.save!
attachment
end
it 'preserves meta data with file attachments' do
expect(image_attachment.meta['description']).to eq('Test image')
end
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) }
before do
allow(GlobalConfigService).to receive(:load).and_call_original
end
it 'respects configured limit' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('5')
attachment.errors.clear
attachment.send(:validate_file_size, 4.megabytes)
expect(attachment.errors[:file]).to be_empty
attachment.errors.clear
attachment.send(:validate_file_size, 6.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
it 'falls back to default when configured limit is invalid' do
allow(GlobalConfigService).to receive(:load)
.with('MAXIMUM_FILE_UPLOAD_SIZE', 40)
.and_return('-10')
attachment.errors.clear
attachment.send(:validate_file_size, 41.megabytes)
expect(attachment.errors[:file]).to include('size is too big')
end
end
end