Files
leadchat/spec/controllers/api/v1/widget/messages_controller_spec.rb
Muhsin Keloth b3d0af84c4 fix(widget): Queue SDK-set conversation attributes and labels for first message (#13912)
### Description

When integrating the web widget via the JS SDK, customers call
setConversationCustomAttributes and setLabel on chatwoot:ready — before
any conversation exists. These API calls silently fail because the
backend endpoints require an existing conversation. When the visitor
sends their first message, the conversation is created without those
attributes/labels, so the message_created webhook payload is missing the
expected metadata.

This change queues SDK-set conversation custom attributes and labels in
the widget store when no conversation exists yet, and includes them in
the API request when the first message (or attachment) creates the
conversation. The backend now permits and applies these params during
conversation creation — before the message is saved and webhooks fire.

###  How to test

  1. Configure a web widget without a pre-chat form.
2. Open the widget on a test page and run the following in the browser
console after chatwoot:ready:
`window.$chatwoot.setConversationCustomAttributes({ plan: 'enterprise'
});`
`window.$chatwoot.setLabel('vip');` // must be a label that exists in
the account
  3. Send the first message from the widget.
4. Verify in the Chatwoot dashboard that the conversation has plan:
enterprise in custom attributes and the vip label applied.
5. Set up a webhook subscriber for `message_created` confirm the first
payload includes the conversation metadata.
6. Verify that calling `setConversationCustomAttributes` / `setLabel` on
an existing conversation still works as before (direct API path, no
regression).
  7. Verify the pre-chat form flow still works as expected.
2026-04-02 12:09:24 +04:00

280 lines
13 KiB
Ruby

require 'rails_helper'
RSpec.describe '/api/v1/widget/messages', type: :request do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:contact) { create(:contact, account: account, email: nil) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:conversation) { create(:conversation, contact: contact, account: account, inbox: web_widget.inbox, contact_inbox: contact_inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
let(:token) { Widget::TokenService.new(payload: payload).generate_token }
before do |example|
2.times.each { create(:message, account: account, inbox: web_widget.inbox, conversation: conversation) } unless example.metadata[:skip_before]
end
describe 'GET /api/v1/widget/messages' do
context 'when get request is made' do
it 'returns messages in conversation' do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
# 2 messages created + 2 messages by the email hook
expect(json_response['payload'].length).to eq(4)
expect(json_response['meta']).not_to be_empty
end
it 'returns empty messages', :skip_before do
get api_v1_widget_messages_url,
params: { website_token: web_widget.website_token },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['payload'].length).to eq(0)
end
end
end
describe 'POST /api/v1/widget/messages' do
context 'when post request is made' do
it 'creates message in conversation' do
conversation.destroy! # Test all params
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['content']).to eq(message_params[:content])
end
it 'creates conversation with custom_attributes when first message is sent' do
conversation.destroy!
message_params = { content: 'hello world', timestamp: Time.current }
custom_attributes = { plan: 'enterprise', source: 'website' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, custom_attributes: custom_attributes },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.custom_attributes).to include('plan' => 'enterprise', 'source' => 'website')
end
it 'creates conversation with labels when first message is sent' do
conversation.destroy!
label = create(:label, title: 'vip', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, labels: [label.title] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.label_list).to include('vip')
end
it 'ignores invalid labels when creating conversation with first message' do
conversation.destroy!
create(:label, title: 'valid-label', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params, labels: %w[valid-label nonexistent] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
new_conversation = contact.conversations.last
expect(new_conversation.label_list).to include('valid-label')
expect(new_conversation.label_list).not_to include('nonexistent')
end
it 'does not apply labels or custom_attributes when conversation already exists' do
create(:label, title: 'vip', account: account)
message_params = { content: 'hello world', timestamp: Time.current }
custom_attributes = { plan: 'enterprise' }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params,
custom_attributes: custom_attributes, labels: ['vip'] },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
conversation.reload
expect(conversation.custom_attributes).not_to include('plan' => 'enterprise')
expect(conversation.label_list).not_to include('vip')
end
it 'does not create the message' do
conversation.destroy! # Test all params
message_params = { content: "#{'h' * 150 * 1000}a", timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:unprocessable_entity)
json_response = response.parsed_body
expect(json_response['message']).to eq('Content is too long (maximum is 150000 characters)')
end
it 'creates message in conversation with a valid reply to' do
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['content']).to eq(message_params[:content])
expect(json_response['content_attributes']['in_reply_to']).to eq(conversation.messages.first.id)
# check nil for external id since this is a web widget conversation
expect(json_response['content_attributes']['in_reply_to_external_id']).to be_nil
end
it 'creates message in conversation with an in-valid reply to' do
message_params = { content: 'hello world reply', timestamp: Time.current, reply_to: conversation.messages.first.id + 300 }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['content']).to eq(message_params[:content])
expect(json_response['content_attributes']['in_reply_to']).to be_nil
expect(json_response['content_attributes']['in_reply_to_external_id']).to be_nil
end
it 'creates attachment message in conversation' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
message_params = { content: 'hello world', timestamp: Time.current, attachments: [file] }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token }
expect(response).to have_http_status(:success)
json_response = response.parsed_body
expect(json_response['content']).to eq(message_params[:content])
expect(conversation.messages.last.attachments.first.file.present?).to be(true)
expect(conversation.messages.last.attachments.first.file_type).to eq('image')
end
it 'does not reopen conversation when conversation is muted' do
conversation.mute!
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
expect(conversation.reload.resolved?).to be(true)
end
it 'does not create resolved activity messages when snoozed conversation is opened' do
conversation.snoozed!
message_params = { content: 'hello world', timestamp: Time.current }
post api_v1_widget_messages_url,
params: { website_token: web_widget.website_token, message: message_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(Conversations::ActivityMessageJob).not_to have_been_enqueued.at_least(:once).with(
conversation,
{
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
message_type: :activity,
content: "Conversation was resolved by #{contact.name}"
}
)
expect(response).to have_http_status(:success)
expect(conversation.reload.open?).to be(true)
end
end
end
describe 'PUT /api/v1/widget/messages' do
context 'when put request is made with non existing email' do
it 'updates message in conversation and creates a new contact' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect(message.submitted_email).to eq(email)
expect(message.conversation.contact.email).to eq(email)
expect(message.conversation.contact.name).to eq(email.split('@')[0])
end
end
context 'when put request is made with invalid email' do
it 'rescues the error' do
message = create(:message, account: account, content_type: 'input_email', inbox: web_widget.inbox, conversation: conversation)
contact_params = { email: nil }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
end
end
context 'when put request is made with existing email' do
it 'updates message in conversation and deletes the current contact' do
message = create(:message, account: account, content_type: 'input_email', inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
existing_contact = create(:contact, account: account, email: email, name: 'John Doe')
contact_params = { email: email }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect(existing_contact.reload.name).to eq('John Doe')
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
it 'ignores the casing of email, updates message in conversation and deletes the current contact' do
message = create(:message, content_type: 'input_email', account: account, inbox: web_widget.inbox, conversation: conversation)
email = Faker::Internet.email
create(:contact, account: account, email: email)
contact_params = { email: email.upcase }
put api_v1_widget_message_url(message.id),
params: { website_token: web_widget.website_token, contact: contact_params },
headers: { 'X-Auth-Token' => token },
as: :json
expect(response).to have_http_status(:success)
message.reload
expect { contact.reload }.to raise_error(ActiveRecord::RecordNotFound)
end
end
end
end