Initial commit: Add logistics and order_detail message types
Some checks failed
Lock Threads / action (push) Has been cancelled
Mark stale issues and pull requests / stale (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot EE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot EE docker images / merge (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Publish Chatwoot CE docker images / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Publish Chatwoot CE docker images / merge (push) Has been cancelled
Run Chatwoot CE spec / lint-backend (push) Has been cancelled
Run Chatwoot CE spec / lint-frontend (push) Has been cancelled
Run Chatwoot CE spec / frontend-tests (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (0, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (1, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (10, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (11, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (12, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (13, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (14, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (15, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (2, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (3, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (4, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (5, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (6, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (7, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (8, 16) (push) Has been cancelled
Run Chatwoot CE spec / backend-tests (9, 16) (push) Has been cancelled
Run Linux nightly installer / nightly (push) Has been cancelled

- Add Logistics component with progress tracking
- Add OrderDetail component for order information
- Support data-driven steps and actions
- Add blue color scale to widget SCSS
- Fix node overflow and progress bar rendering issues
- Add English translations for dashboard components

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Liang XJ
2026-01-26 11:16:56 +08:00
commit 092fb2e083
7646 changed files with 975643 additions and 0 deletions

View File

@@ -0,0 +1,48 @@
require 'rails_helper'
RSpec.describe BillingHelper do
describe '#conversations_this_month' do
let(:user) { create(:user) }
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Hacker' }) }
before do
create(:installation_config, {
name: 'CHATWOOT_CLOUD_PLANS',
value: [
{
'name' => 'Hacker',
'product_id' => ['plan_id'],
'price_ids' => ['price_1']
},
{
'name' => 'Startups',
'product_id' => ['plan_id_2'],
'price_ids' => ['price_2']
}
]
})
end
it 'counts only the conversations created this month' do
create_list(:conversation, 5, account: account, created_at: Time.zone.today - 1.day)
create_list(:conversation, 3, account: account, created_at: 2.months.ago)
expect(helper.send(:conversations_this_month, account)).to eq(5)
end
it 'counts only non web widget channels' do
create(:inbox, account: account, channel_type: Channel::WebWidget)
expect(account.inboxes.count).to eq(1)
expect(helper.send(:non_web_inboxes, account)).to eq(0)
create(:inbox, account: account, channel_type: Channel::Api)
expect(account.inboxes.count).to eq(2)
expect(helper.send(:non_web_inboxes, account)).to eq(1)
end
it 'returns true for the default plan name' do
expect(helper.send(:default_plan?, account)).to be(true)
account.custom_attributes['plan_name'] = 'Startups'
expect(helper.send(:default_plan?, account)).to be(false)
end
end
end

View File

@@ -0,0 +1,34 @@
require 'rails_helper'
RSpec.describe CacheKeysHelper do
let(:account_id) { 1 }
let(:key) { 'example_key' }
describe '#get_prefixed_cache_key' do
it 'returns a string with the correct prefix, account ID, and key' do
expected_key = "idb-cache-key-account-#{account_id}-#{key}"
result = helper.get_prefixed_cache_key(account_id, key)
expect(result).to eq(expected_key)
end
end
describe '#fetch_value_for_key' do
it 'returns the zero epoch time if no value is cached' do
result = helper.fetch_value_for_key(account_id, 'another-key')
expect(result).to eq('0000000000')
end
it 'returns a cached value if it exists' do
value = Time.now.to_i
prefixed_cache_key = helper.get_prefixed_cache_key(account_id, key)
Redis::Alfred.set(prefixed_cache_key, value)
result = helper.fetch_value_for_key(account_id, key)
expect(result).to eq(value.to_s)
end
end
end

View File

@@ -0,0 +1,107 @@
require 'rails_helper'
RSpec.describe ContactHelper do
describe '#parse_name' do
it 'correctly splits a full name into first and last name' do
full_name = 'John Doe Smith'
expected_result = { first_name: 'John', last_name: 'Smith', middle_name: 'Doe', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles single-word names correctly' do
full_name = 'Cher'
expected_result = { first_name: 'Cher', last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles an empty string correctly' do
full_name = ''
expected_result = { first_name: nil, last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles multiple consecutive spaces correctly' do
full_name = 'John Doe Smith'
expected_result = { last_name: 'Smith', first_name: 'John', middle_name: 'Doe', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'returns nil for first and last name when input is nil' do
full_name = nil
expected_result = { first_name: nil, last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with special characters correctly' do
full_name = 'John Doe-Smith'
expected_result = { first_name: 'John', last_name: 'Doe-Smith', middle_name: '', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles non-Latin script names correctly' do
full_name = '李 小龙'
expected_result = { first_name: '李', last_name: '小龙', middle_name: '', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handle name with trailing spaces correctly' do
full_name = 'John Doe Smith '
expected_result = { first_name: 'John', last_name: 'Smith', middle_name: 'Doe', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handle name with leading spaces correctly' do
full_name = ' John Doe Smith'
expected_result = { first_name: 'John', last_name: 'Smith', middle_name: 'Doe', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handle name with phone number correctly' do
full_name = '+1234567890'
expected_result = { first_name: '+1234567890', last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handle name with mobile number with spaces correctly' do
full_name = '+1 234 567 890'
expected_result = { first_name: '+1 234 567 890', last_name: nil, middle_name: nil, prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'correctly splits a full name with middle name' do
full_name = 'John Quincy Adams'
expected_result = { first_name: 'John', last_name: 'Adams', middle_name: 'Quincy', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with multiple spaces between first and last name' do
full_name = 'John Quincy Adams'
expected_result = { first_name: 'John', last_name: 'Adams', middle_name: 'Quincy', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with leading and trailing whitespaces' do
full_name = ' John Quincy Adams '
expected_result = { first_name: 'John', last_name: 'Adams', middle_name: 'Quincy', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with leading and trailing whitespaces and a middle initial' do
full_name = ' John Q. Adams '
expected_result = { first_name: 'John', last_name: 'Adams', middle_name: 'Q.', prefix: nil, suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with a prefix' do
full_name = 'Mr. John Doe'
expected_result = { first_name: 'John', last_name: 'Doe', middle_name: '', prefix: 'Mr.', suffix: nil }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
it 'handles names with a suffix' do
full_name = 'John Doe Jr.'
expected_result = { first_name: 'John', last_name: 'Doe', middle_name: '', prefix: nil, suffix: 'Jr.' }
expect(helper.parse_name(full_name)).to eq(expected_result)
end
end
end

View File

@@ -0,0 +1,19 @@
require 'rails_helper'
describe EmailHelper do
describe '#normalize_email_with_plus_addressing' do
context 'when email is passed' do
it 'normalise if plus addressing is present' do
expect(helper.normalize_email_with_plus_addressing('john+test@acme.inc')).to eq 'john@acme.inc'
end
it 'returns original if plus addressing is not present' do
expect(helper.normalize_email_with_plus_addressing('john@acme.inc')).to eq 'john@acme.inc'
end
it 'returns downcased version of email' do
expect(helper.normalize_email_with_plus_addressing('JoHn+AAsdfss@acme.inc')).to eq 'john@acme.inc'
end
end
end
end

View File

@@ -0,0 +1,17 @@
require 'rails_helper'
describe FrontendUrlsHelper do
describe '#frontend_url' do
context 'without query params' do
it 'creates path correctly' do
expect(helper.frontend_url('dashboard')).to eq 'http://test.host/app/dashboard'
end
end
context 'with query params' do
it 'creates path correctly' do
expect(helper.frontend_url('dashboard', p1: 'p1', p2: 'p2')).to eq 'http://test.host/app/dashboard?p1=p1&p2=p2'
end
end
end
end

View File

@@ -0,0 +1,98 @@
require 'rails_helper'
RSpec.describe Instagram::IntegrationHelper do
include described_class
describe '#generate_instagram_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_instagram_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_instagram_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Instagram token: Test error')
expect(generate_instagram_token(account_id)).to be_nil
end
end
end
describe '#token_payload' do
let(:account_id) { 1 }
let(:current_time) { Time.current }
before do
allow(Time).to receive(:current).and_return(current_time)
end
it 'returns a hash with the correct structure' do
payload = token_payload(account_id)
expect(payload).to be_a(Hash)
expect(payload[:sub]).to eq(account_id)
expect(payload[:iat]).to eq(current_time.to_i)
end
end
describe '#verify_instagram_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(GlobalConfigService).to receive(:load).with('INSTAGRAM_APP_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_instagram_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_instagram_token('')).to be_nil
expect(verify_instagram_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_instagram_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Instagram token:/)
expect(verify_instagram_token('invalid_token')).to be_nil
end
end
end
end

View File

@@ -0,0 +1,81 @@
require 'rails_helper'
RSpec.describe Linear::IntegrationHelper do
include described_class
describe '#generate_linear_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_linear_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_linear_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Linear token: Test error')
expect(generate_linear_token(account_id)).to be_nil
end
end
end
describe '#verify_linear_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(ENV).to receive(:fetch).with('LINEAR_CLIENT_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_linear_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_linear_token('')).to be_nil
expect(verify_linear_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_linear_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Linear token:/)
expect(verify_linear_token('invalid_token')).to be_nil
end
end
end
end

View File

@@ -0,0 +1,47 @@
require 'rails_helper'
describe MessageFormatHelper do
describe '#transform_user_mention_content' do
context 'when transform_user_mention_content called' do
it 'return transformed text correctly' do
expect(helper.transform_user_mention_content('[@john](mention://user/1/John%20K), check this ticket')).to eq '@john, check this ticket'
end
it 'handles emoji in display names correctly' do
content = '[@👍 customer support](mention://team/1/%F0%9F%91%8D%20customer%20support), please help'
expected = '@👍 customer support, please help'
expect(helper.transform_user_mention_content(content)).to eq expected
end
it 'handles multiple mentions with emojis and spaces' do
content = 'Hey [@John Doe](mention://user/1/John%20Doe) and [@🚀 Dev Team](mention://team/2/%F0%9F%9A%80%20Dev%20Team)'
expected = 'Hey @John Doe and @🚀 Dev Team'
expect(helper.transform_user_mention_content(content)).to eq expected
end
it 'handles emoji-only team names' do
expect(helper.transform_user_mention_content('[@🔥](mention://team/3/%F0%9F%94%A5) urgent')).to eq '@🔥 urgent'
end
it 'handles special characters in names' do
expect(helper.transform_user_mention_content('[@user@domain.com](mention://user/4/user%40domain.com) check')).to eq '@user@domain.com check'
end
it 'returns empty string for nil content' do
expect(helper.transform_user_mention_content(nil)).to eq ''
end
it 'returns empty string for empty content' do
expect(helper.transform_user_mention_content('')).to eq ''
end
end
end
describe '#render_message_content' do
context 'when render_message_content called' do
it 'render text correctly' do
expect(helper.render_message_content('Hi *there*, I am mostly text!')).to eq "<p>Hi <em>there</em>, I am mostly text!</p>\n"
end
end
end
end

View File

@@ -0,0 +1,330 @@
require 'rails_helper'
describe PortalHelper do
describe '#generate_portal_bg_color' do
context 'when theme is dark' do
it 'returns the correct color mix with black' do
expect(helper.generate_portal_bg_color('#ff0000', 'dark')).to eq(
'color-mix(in srgb, #ff0000 20%, black)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct color mix with white' do
expect(helper.generate_portal_bg_color('#ff0000', 'light')).to eq(
'color-mix(in srgb, #ff0000 20%, white)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the color mix appropriately' do
expect(helper.generate_portal_bg_color('#00ff00', 'dark')).to eq(
'color-mix(in srgb, #00ff00 20%, black)'
)
expect(helper.generate_portal_bg_color('#0000ff', 'light')).to eq(
'color-mix(in srgb, #0000ff 20%, white)'
)
end
end
end
describe '#generate_portal_bg' do
context 'when theme is dark' do
it 'returns the correct background with dark grid image and color mix with black' do
expected_bg = 'color-mix(in srgb, #ff0000 20%, black)'
expect(helper.generate_portal_bg('#ff0000', 'dark')).to eq(expected_bg)
end
end
context 'when theme is not dark' do
it 'returns the correct background with light grid image and color mix with white' do
expected_bg = 'color-mix(in srgb, #ff0000 20%, white)'
expect(helper.generate_portal_bg('#ff0000', 'light')).to eq(expected_bg)
end
end
context 'when provided with various colors' do
it 'adjusts the background appropriately for dark theme' do
expected_bg = 'color-mix(in srgb, #00ff00 20%, black)'
expect(helper.generate_portal_bg('#00ff00', 'dark')).to eq(expected_bg)
end
it 'adjusts the background appropriately for light theme' do
expected_bg = 'color-mix(in srgb, #0000ff 20%, white)'
expect(helper.generate_portal_bg('#0000ff', 'light')).to eq(expected_bg)
end
end
end
describe '#generate_gradient_to_bottom' do
context 'when theme is dark' do
it 'returns the correct gradient' do
expect(helper.generate_gradient_to_bottom('dark')).to eq(
'linear-gradient(to bottom, transparent, #151718)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct gradient' do
expect(helper.generate_gradient_to_bottom('light')).to eq(
'linear-gradient(to bottom, transparent, white)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the gradient appropriately' do
expect(helper.generate_gradient_to_bottom('dark')).to eq(
'linear-gradient(to bottom, transparent, #151718)'
)
expect(helper.generate_gradient_to_bottom('light')).to eq(
'linear-gradient(to bottom, transparent, white)'
)
end
end
end
describe '#generate_portal_hover_color' do
context 'when theme is dark' do
it 'returns the correct color mix with #1B1B1B' do
expect(helper.generate_portal_hover_color('#ff0000', 'dark')).to eq(
'color-mix(in srgb, #ff0000 5%, #1B1B1B)'
)
end
end
context 'when theme is not dark' do
it 'returns the correct color mix with #F9F9F9' do
expect(helper.generate_portal_hover_color('#ff0000', 'light')).to eq(
'color-mix(in srgb, #ff0000 5%, #F9F9F9)'
)
end
end
context 'when provided with various colors' do
it 'adjusts the color mix appropriately' do
expect(helper.generate_portal_hover_color('#00ff00', 'dark')).to eq(
'color-mix(in srgb, #00ff00 5%, #1B1B1B)'
)
expect(helper.generate_portal_hover_color('#0000ff', 'light')).to eq(
'color-mix(in srgb, #0000ff 5%, #F9F9F9)'
)
end
end
end
describe '#theme_query_string' do
context 'when theme is present and not system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('dark')).to eq('?theme=dark')
end
end
context 'when theme is not present' do
it 'returns the correct query string' do
expect(helper.theme_query_string(nil)).to eq('')
end
end
context 'when theme is system' do
it 'returns the correct query string' do
expect(helper.theme_query_string('system')).to eq('')
end
end
end
describe '#generate_home_link' 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'
)
end
end
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'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_home_link('portal_slug', 'en', 'dark', false)).to eq(
'/hc/portal_slug/en'
)
end
end
end
describe '#generate_category_link' do
context 'when theme is not present' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: nil,
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end
end
context 'when theme is present and plain layout is enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: true
)).to eq(
'/hc/portal_slug/en/categories/category_slug?theme=dark'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_category_link(
portal_slug: 'portal_slug',
category_locale: 'en',
category_slug: 'category_slug',
theme: 'dark',
is_plain_layout_enabled: false
)).to eq(
'/hc/portal_slug/en/categories/category_slug'
)
end
end
end
describe '#generate_article_link' 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'
)
end
end
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'
)
end
end
context 'when plain layout is not enabled' do
it 'returns the correct link' do
expect(helper.generate_article_link('portal_slug', 'article_slug', 'dark', false)).to eq(
'/hc/portal_slug/articles/article_slug'
)
end
end
end
describe '#render_category_content' do
let(:markdown_content) { 'This is a *test* markdown content' }
let(:plain_text_content) { 'This is a test markdown content' }
let(:renderer) { instance_double(ChatwootMarkdownRenderer) }
before do
allow(ChatwootMarkdownRenderer).to receive(:new).with(markdown_content).and_return(renderer)
allow(renderer).to receive(:render_markdown_to_plain_text).and_return(plain_text_content)
end
it 'converts markdown to plain text' do
expect(helper.render_category_content(markdown_content)).to eq(plain_text_content)
end
end
describe '#thumbnail_bg_color' do
it 'returns the correct color based on username length' do
expect(helper.thumbnail_bg_color('')).to be_in(['#6D95BA', '#A4C3C3', '#E19191'])
expect(helper.thumbnail_bg_color('Joe')).to eq('#6D95BA')
expect(helper.thumbnail_bg_color('John')).to eq('#A4C3C3')
expect(helper.thumbnail_bg_color('Jane james')).to eq('#A4C3C3')
expect(helper.thumbnail_bg_color('Jane_123')).to eq('#E19191')
expect(helper.thumbnail_bg_color('AlexanderTheGreat')).to eq('#E19191')
expect(helper.thumbnail_bg_color('Reginald John Sans')).to eq('#6D95BA')
end
end
describe '#set_og_image_url' do
let(:portal_name) { 'Chatwoot Portal' }
let(:title) { 'Welcome to Chatwoot' }
context 'when CDN URL is present' do
before do
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: 'https://cdn.example.com')
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
end
it 'returns the composed OG image URL with correct params' do
result = helper.set_og_image_url(portal_name, title)
uri = URI.parse(result)
expect(uri.path).to eq('/og')
params = Rack::Utils.parse_query(uri.query)
expect(params['clientRef']).to eq('client-123')
expect(params['title']).to eq(title)
expect(params['portalName']).to eq(portal_name)
end
end
context 'when CDN URL is blank' do
before do
InstallationConfig.create!(name: 'OG_IMAGE_CDN_URL', value: '')
InstallationConfig.create!(name: 'OG_IMAGE_CLIENT_REF', value: 'client-123')
end
it 'returns nil' do
expect(helper.set_og_image_url(portal_name, title)).to be_nil
end
end
end
describe '#generate_portal_brand_url' do
it 'builds URL with UTM params and referer host as source (happy path)' do
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
uri = URI.parse(result)
params = Rack::Utils.parse_query(uri.query)
expect(uri.scheme).to eq('https')
expect(uri.host).to eq('brand.example.com')
expect(params['utm_medium']).to eq('helpcenter')
expect(params['utm_campaign']).to eq('branding')
expect(params['utm_source']).to eq('app.chatwoot.com')
end
it 'returns utm string when brand_url is nil or empty' do
expect(helper.generate_portal_brand_url(nil,
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
expect(helper.generate_portal_brand_url('',
'https://app.chatwoot.com')).to eq(
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
)
end
it 'omits utm_source when referer is nil or invalid' do
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
expect(p1.key?('utm_source')).to be(false)
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
expect(p2.key?('utm_source')).to be(false)
expect(p2['utm_medium']).to eq('helpcenter')
expect(p2['utm_campaign']).to eq('branding')
end
end
end

View File

@@ -0,0 +1,177 @@
require 'rails_helper'
RSpec.describe ReportingEventHelper, type: :helper do
describe '#last_non_human_activity' do
let(:account) { create(:account) }
let(:inbox) { create(:inbox, account: account) }
let(:user) { create(:user, account: account) }
let(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: user) }
context 'when conversation has no events' do
it 'returns conversation created_at' do
expect(helper.last_non_human_activity(conversation)).to eq(conversation.created_at)
end
end
context 'when conversation has bot handoff event' do
let!(:handoff_event) do
create(:reporting_event,
name: 'conversation_bot_handoff',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 2.hours.ago)
end
it 'returns handoff event end time' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(handoff_event.event_end_time.to_i)
end
end
context 'when conversation has bot resolved event' do
let!(:bot_resolved_event) do
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 3.hours.ago)
end
it 'returns bot resolved event end time' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(bot_resolved_event.event_end_time.to_i)
end
end
context 'when conversation is reopened after bot resolution' do
let(:creation_time) { 5.days.ago }
let(:bot_resolution_time) { 5.days.ago + 5.minutes }
let(:reopening_time) { 1.hour.ago }
let!(:conversation) do
create(:conversation,
account: account,
inbox: inbox,
assignee: user,
created_at: creation_time)
end
before do
# First opened event
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 0,
event_start_time: creation_time,
event_end_time: creation_time)
# Bot resolved event
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_start_time: creation_time,
event_end_time: bot_resolution_time)
# Resolved event
create(:reporting_event,
name: 'conversation_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_start_time: creation_time,
event_end_time: bot_resolution_time)
# Reopened event
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: (reopening_time - bot_resolution_time).to_i,
event_start_time: bot_resolution_time,
event_end_time: reopening_time)
end
it 'returns the reopening event time, not the creation time' do
# This is the key test: last_non_human_activity should return the reopening time
# so that first response time is calculated from when the conversation was reopened,
# not from when it was originally created
expect(helper.last_non_human_activity(conversation).to_i).to eq(reopening_time.to_i)
# Verify it's not returning the creation time or bot resolution time
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(creation_time.to_i)
expect(helper.last_non_human_activity(conversation).to_i).not_to eq(bot_resolution_time.to_i)
end
end
context 'when conversation has multiple types of events' do
let(:opened_event_time) { 1.hour.ago }
before do
create(:reporting_event,
name: 'conversation_bot_resolved',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 4.hours.ago)
create(:reporting_event,
name: 'conversation_bot_handoff',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: 3.hours.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
event_end_time: opened_event_time)
end
it 'returns the most recent handoff or opened event' do
# opened_event is more recent than handoff_event
expect(helper.last_non_human_activity(conversation).to_i).to eq(opened_event_time.to_i)
end
end
context 'when conversation has multiple reopenings' do
let(:third_opened_time) { 30.minutes.ago }
before do
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 0,
event_end_time: 5.days.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 3600,
event_end_time: 2.days.ago)
create(:reporting_event,
name: 'conversation_opened',
conversation_id: conversation.id,
account_id: account.id,
inbox_id: inbox.id,
value: 7200,
event_end_time: third_opened_time)
end
it 'returns the most recent opened event' do
expect(helper.last_non_human_activity(conversation).to_i).to eq(third_opened_time.to_i)
end
end
end
end

View File

@@ -0,0 +1,95 @@
require 'rails_helper'
RSpec.describe Shopify::IntegrationHelper do
include described_class
describe '#generate_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:current_time) { Time.current }
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
allow(Time).to receive(:current).and_return(current_time)
end
it 'generates a valid JWT token with correct payload' do
token = generate_shopify_token(account_id)
decoded_token = JWT.decode(token, client_secret, true, algorithm: 'HS256').first
expect(decoded_token['sub']).to eq(account_id)
expect(decoded_token['iat']).to eq(current_time.to_i)
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(generate_shopify_token(account_id)).to be_nil
end
end
context 'when an error occurs' do
before do
allow(JWT).to receive(:encode).and_raise(StandardError.new('Test error'))
end
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with('Failed to generate Shopify token: Test error')
expect(generate_shopify_token(account_id)).to be_nil
end
end
end
describe '#verify_shopify_token' do
let(:account_id) { 1 }
let(:client_secret) { 'test_secret' }
let(:valid_token) do
JWT.encode({ sub: account_id, iat: Time.current.to_i }, client_secret, 'HS256')
end
before do
allow(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil).and_return(client_secret)
end
it 'successfully verifies and returns account_id from valid token' do
expect(verify_shopify_token(valid_token)).to eq(account_id)
end
context 'when token is blank' do
it 'returns nil' do
expect(verify_shopify_token('')).to be_nil
expect(verify_shopify_token(nil)).to be_nil
end
end
context 'when client secret is not configured' do
let(:client_secret) { nil }
it 'returns nil' do
expect(verify_shopify_token(valid_token)).to be_nil
end
end
context 'when token is invalid' do
it 'logs the error and returns nil' do
expect(Rails.logger).to receive(:error).with(/Unexpected error verifying Shopify token:/)
expect(verify_shopify_token('invalid_token')).to be_nil
end
end
end
describe '#client_id' do
it 'loads client_id from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_ID', nil)
client_id
end
end
describe '#client_secret' do
it 'loads client_secret from GlobalConfigService' do
expect(GlobalConfigService).to receive(:load).with('SHOPIFY_CLIENT_SECRET', nil)
client_secret
end
end
end

View File

@@ -0,0 +1,15 @@
require 'rails_helper'
describe UrlHelper do
describe '#url_valid' do
context 'when url valid called' do
it 'return if valid url passed' do
expect(helper.url_valid?('https://app.chatwoot.com/')).to be true
end
it 'return false if invalid url passed' do
expect(helper.url_valid?('javascript:alert(document.cookie)')).to be false
end
end
end
end