Merge branch 'develop' into feat/read-only-token

This commit is contained in:
Shivam Mishra
2026-06-03 12:05:54 +05:30
committed by GitHub
560 changed files with 9067 additions and 1506 deletions
@@ -149,6 +149,35 @@ describe Messages::MessageBuilder do
end
end
context 'when is_voice_message is true' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/sample.ogg', 'audio/ogg')],
is_voice_message: true
})
end
it 'sets is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).to include('is_voice_message' => true)
end
end
context 'when is_voice_message is not provided' do
let(:params) do
ActionController::Parameters.new({
content: 'test',
attachments: [Rack::Test::UploadedFile.new('spec/assets/avatar.png', 'image/png')]
})
end
it 'does not set is_voice_message in attachment meta' do
message = message_builder
expect(message.attachments.first.meta).not_to include('is_voice_message')
end
end
context 'when email channel messages' do
let!(:channel_email) { create(:channel_email, account: account) }
let(:inbox_member) { create(:inbox_member, inbox: channel_email.inbox) }
@@ -126,32 +126,47 @@ RSpec.describe 'Conversations API', type: :request do
Conversations::UnreadCounts::Store.clear_account!(account.id)
end
it 'returns unread conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
context 'when conversation unread counts feature is enabled' do
before do
account.enable_features!(:conversation_unread_counts)
end
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
it 'returns unread conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, labels: [label.title])
create_unread_conversation(account: account, inbox: hidden_inbox, labels: [label.title])
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']).to eq(
'inboxes' => { visible_inbox.id.to_s => 1 },
'labels' => { label.id.to_s => 1 },
'teams' => {}
)
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
end
end
it 'returns unread team conversation counts scoped to the signed-in user' do
create_unread_conversation(account: account, inbox: visible_inbox, team: team)
create_unread_conversation(account: account, inbox: hidden_inbox, team: team)
it 'returns forbidden when conversation unread counts feature is disabled' do
get "/api/v1/accounts/#{account.id}/conversations/unread_counts",
headers: agent.create_new_auth_token,
as: :json
expect(response).to have_http_status(:success)
expect(response.parsed_body['payload']['teams']).to eq(team.id.to_s => 1)
expect(response).to have_http_status(:forbidden)
expect(response.parsed_body['error']).to eq('Conversation unread counts feature not enabled for this account')
end
end
end
@@ -833,6 +848,7 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'refreshes unread count cache when conversation is marked read' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.hour.ago)
create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming, created_at: 5.minutes.ago)
Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -920,6 +936,7 @@ RSpec.describe 'Conversations API', type: :request do
end
it 'refreshes unread count cache when conversation is marked unread' do
account.enable_features!(:conversation_unread_counts)
conversation.update!(agent_last_seen_at: 1.minute.from_now, assignee_last_seen_at: 1.minute.from_now)
Conversations::UnreadCounts::Builder.new(account).build_base!
@@ -43,6 +43,7 @@ RSpec.describe 'Microsoft Authorization API', type: :request do
]
expect(params['scope']).to eq(expected_scope)
expect(params['redirect_uri']).to eq(["#{ENV.fetch('FRONTEND_URL', 'http://localhost:3000')}/microsoft/callback"])
expect(url).not_to match(/(?:\?|&)prompt=/)
# Validate state parameter exists and can be decoded back to the account
expect(params['state']).to be_present
+6 -6
View File
@@ -140,7 +140,7 @@ describe PortalHelper do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', nil, true)).to eq(
'/hc/portal_slug/en'
'/hc/portal_slug/en?show_plain_layout=true'
)
end
end
@@ -148,7 +148,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', true)).to eq(
'/hc/portal_slug/en?theme=dark'
'/hc/portal_slug/en?show_plain_layout=true&theme=dark'
)
end
end
@@ -172,7 +172,7 @@ describe PortalHelper do
theme: nil,
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true'
)
end
end
@@ -186,7 +186,7 @@ describe PortalHelper do
theme: 'dark',
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug?theme=dark'
'/hc/portal_slug/en/categories/category_slug?show_plain_layout=true&theme=dark'
)
end
end
@@ -210,7 +210,7 @@ describe PortalHelper do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', nil, true)).to eq(
'/hc/portal_slug/articles/article_slug'
'/hc/portal_slug/articles/article_slug?show_plain_layout=true'
)
end
end
@@ -218,7 +218,7 @@ describe PortalHelper do
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', true)).to eq(
'/hc/portal_slug/articles/article_slug?theme=dark'
'/hc/portal_slug/articles/article_slug?show_plain_layout=true&theme=dark'
)
end
end
@@ -237,6 +237,10 @@ describe ActionCableListener do
let!(:agent_without_inbox_access) { create(:user, account: account, role: :agent) }
let!(:event) { Events::Base.new(event_name, Time.zone.now, conversation: conversation) }
before do
account.enable_features!(:conversation_unread_counts)
end
it 'sends a lightweight refresh event to inbox agents and admins' do
expect(conversation.inbox.reload.inbox_members.count).to eq(1)
@@ -261,6 +265,14 @@ describe ActionCableListener do
listener.conversation_unread_count_changed(event)
end
it 'does not broadcast when conversation unread counts feature is disabled' do
account.disable_features!(:conversation_unread_counts)
expect(ActionCableBroadcastJob).not_to receive(:perform_later)
listener.conversation_unread_count_changed(event)
end
it 'supports deleted conversation data' do
event = Events::Base.new(
event_name,
+38
View File
@@ -50,6 +50,44 @@ RSpec.describe Account do
end
end
describe 'conversation unread counts feature flag' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:store) { Conversations::UnreadCounts::Store }
let(:inbox_key) { store.inbox_key(account.id, inbox.id) }
after do
store.clear_account!(account.id)
end
it 'clears unread count cache when the feature is enabled' do
build_unread_count_cache
account.enable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
it 'clears unread count cache when the feature is disabled' do
account.enable_features!(:conversation_unread_counts)
build_unread_count_cache
account.disable_features!(:conversation_unread_counts)
expect(store.base_ready?(account.id)).to be(false)
expect(store.assignment_ready?(account.id)).to be(false)
expect(store.counts_for_keys([inbox_key])).to eq(inbox_key => 0)
end
def build_unread_count_cache
store.mark_base_ready!(account.id)
store.mark_assignment_ready!(account.id)
store.add_base_membership(account_id: account.id, inbox_id: inbox.id, label_ids: [], conversation_id: 1)
end
end
describe 'inbound_email_domain' do
let(:account) { create(:account) }
+9
View File
@@ -20,6 +20,15 @@ RSpec.describe Article do
expect(article).not_to be_valid
expect(article.errors[:content]).to include("can't be blank")
end
it 'rejects reserved slugs that collide with help center routes' do
Article::RESERVED_SLUGS.each do |reserved_slug|
article = build(:article, portal_id: portal_1.id, author_id: user.id, category_id: category_1.id,
title: reserved_slug, slug: reserved_slug, content: 'content')
expect(article).not_to be_valid
expect(article.errors[:slug]).to include('is reserved')
end
end
end
describe 'associations' do
@@ -11,6 +11,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end
it 'refreshes unread counts when an incoming message is created' do
account.enable_features!(:conversation_unread_counts)
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
@@ -29,6 +30,17 @@ RSpec.describe Conversations::UnreadCounts::Listener do
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'ignores incoming message creation when conversation unread counts are disabled' do
message = create(:message, account: account, inbox: conversation.inbox, conversation: conversation, message_type: :incoming)
event = Events::Base.new('message.created', Time.zone.now, message: message)
expect(message).not_to receive(:conversation)
listener.message_created(event)
expect(Conversations::UnreadCounts::Notifier).not_to have_received(:new)
end
it 'refreshes unread counts when conversation status changes' do
changed_attributes = { 'status' => %w[open resolved] }
event = Events::Base.new('conversation.status_changed', Time.zone.now, conversation: conversation, changed_attributes: changed_attributes)
@@ -78,6 +90,7 @@ RSpec.describe Conversations::UnreadCounts::Listener do
end
it 'removes unread count memberships when a conversation is deleted' do
account.enable_features!(:conversation_unread_counts)
label = create(:label, account: account)
team = create(:team, account: account)
assignee = create(:user, account: account)
@@ -6,6 +6,7 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
let(:refresh_result) { true }
before do
conversation.account.enable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Refresher).to receive(:new).and_return(refresher)
allow(Rails.configuration.dispatcher).to receive(:dispatch)
end
@@ -29,4 +30,19 @@ RSpec.describe Conversations::UnreadCounts::Notifier do
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
context 'when conversation unread counts feature is disabled' do
before do
conversation.account.disable_features!(:conversation_unread_counts)
allow(Conversations::UnreadCounts::Store).to receive(:clear_account!)
end
it 'does not refresh, clear cache, or dispatch unread count changed event' do
described_class.new(conversation).perform
expect(Conversations::UnreadCounts::Refresher).not_to have_received(:new)
expect(Conversations::UnreadCounts::Store).not_to have_received(:clear_account!)
expect(Rails.configuration.dispatcher).not_to have_received(:dispatch)
end
end
end
@@ -0,0 +1,41 @@
require 'rails_helper'
RSpec.describe Messages::WebhookContentNormalizer do
describe '.normalize' do
it 'returns nil unchanged' do
expect(described_class.normalize(nil)).to be_nil
end
it 'returns blank string unchanged' do
expect(described_class.normalize('')).to eq('')
end
it 'strips trailing newlines added by TipTap/ProseMirror' do
expect(described_class.normalize("hello\n\n\n")).to eq('hello')
end
it 'preserves intentional trailing spaces' do
expect(described_class.normalize("hello \n\n")).to eq('hello ')
end
it 'replaces CommonMark hard line breaks (backslash-newline) with plain newlines' do
expect(described_class.normalize("hello\\\nworld")).to eq("hello\nworld")
end
it 'replaces CommonMark hard line breaks with CRLF with plain newlines' do
expect(described_class.normalize("hello\\\r\nworld")).to eq("hello\nworld")
end
it 'preserves intentional internal newlines' do
expect(described_class.normalize("line one\nline two")).to eq("line one\nline two")
end
it 'strips trailing CRLF newlines without leaving dangling carriage returns' do
expect(described_class.normalize("hello\r\n\r\n")).to eq('hello')
end
it 'handles both hard line breaks and trailing newlines together' do
expect(described_class.normalize("hello\\\nworld\n\n\n")).to eq("hello\nworld")
end
end
end
@@ -60,7 +60,7 @@ describe Whatsapp::Providers::WhatsappCloudService 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')
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
@@ -79,7 +79,7 @@ describe Whatsapp::Providers::WhatsappCloudService do
# ref: https://github.com/bblimke/webmock/issues/900
# reason for Webmock::API.hash_including
stub_request(:post, 'https://graph.facebook.com/v13.0/123456789/messages')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
@@ -91,6 +91,41 @@ describe Whatsapp::Providers::WhatsappCloudService do
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for audio voice message with voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio, meta: { 'is_voice_message' => true })
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'voice.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio',
audio: WebMock::API.hash_including({ link: anything, voice: true })
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
expect(service.send_message('+123456789', message)).to eq 'message_id'
end
it 'calls message endpoints for regular audio attachment without voice flag' do
attachment = message.attachments.new(account_id: message.account_id, file_type: :audio)
attachment.file.attach(io: Rails.root.join('spec/assets/sample.ogg').open, filename: 'audio.ogg', content_type: 'audio/ogg')
stub_request(:post, 'https://graph.facebook.com/v24.0/123456789/messages')
.with(
body: hash_including({
messaging_product: 'whatsapp',
to: '+123456789',
type: 'audio'
})
)
.to_return(status: 200, body: whatsapp_response.to_json, headers: response_headers)
result = service.send_message('+123456789', message)
expect(result).to eq 'message_id'
end
end
end