Voice calling is now a capability on the existing TwilioSms rather than a separate Voice model. A single Twilio phone number handles both SMS and voice calls through one inbox. Fixes https://linear.app/chatwoot/issue/CW-6683/add-voice-calling-as-a-capability-on-twilio-sms-channel and https://linear.app/chatwoot/issue/PLA-120/add-the-support-for-sms **What changed** - Replaced Channel::Voice with voice_enabled flag on Channel::TwilioSms - Added voice_enabled, twiml_app_sid, api_key_secret columns to channel_twilio_sms table - Dropped channel_voice table (no production data) - All voice logic lives in Enterprise layer via prepend_mod_with('Channel::TwilioSms') - Added Voice settings tab on Twilio SMS inbox settings to enable/disable voice - Validates Twilio number voice capability before provisioning - Teardown service cleans up TwiML app and credentials when voice is disabled - Frontend voice detection uses isVoiceCallEnabled() / getVoiceCallProvider() helpers — extensible to future providers - Gated by channel_voice feature flag **How to test** 1. Enable feature flag: Account.find(<id>).enable_features('channel_voice') 2. Create voice inbox: Inboxes → Voice tile → enter Twilio credentials → verify incoming/outgoing calls and SMS work 3. Enable voice on existing SMS inbox: Inboxes → select Twilio SMS inbox → Voice tab → toggle on → provide API key credentials → verify calls work 4. Disable voice: Voice tab → toggle off → verify TwiML app is deleted, credentials cleared, SMS still works 5. Re-enable voice: Toggle on again → must provide api_key_secret again → new TwiML app provisioned --------- Co-authored-by: Muhsin <12408980+muhsin-k@users.noreply.github.com>
291 lines
8.9 KiB
JavaScript
291 lines
8.9 KiB
JavaScript
import { describe, it, expect, vi } from 'vitest';
|
|
import { defineComponent, h } from 'vue';
|
|
import { createStore } from 'vuex';
|
|
import { mount } from '@vue/test-utils';
|
|
import { useInbox } from '../useInbox';
|
|
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
|
|
|
vi.mock('dashboard/composables/store');
|
|
vi.mock('dashboard/composables/useTransformKeys');
|
|
|
|
// Mock the dependencies
|
|
const mockStore = createStore({
|
|
modules: {
|
|
conversations: {
|
|
namespaced: false,
|
|
getters: {
|
|
getSelectedChat: () => ({ inbox_id: 1 }),
|
|
},
|
|
},
|
|
inboxes: {
|
|
namespaced: true,
|
|
getters: {
|
|
getInboxById: () => id => {
|
|
const inboxes = {
|
|
1: {
|
|
id: 1,
|
|
channel_type: INBOX_TYPES.WHATSAPP,
|
|
provider: 'whatsapp_cloud',
|
|
},
|
|
2: { id: 2, channel_type: INBOX_TYPES.FB },
|
|
3: { id: 3, channel_type: INBOX_TYPES.TWILIO, medium: 'sms' },
|
|
4: { id: 4, channel_type: INBOX_TYPES.TWILIO, medium: 'whatsapp' },
|
|
5: {
|
|
id: 5,
|
|
channel_type: INBOX_TYPES.EMAIL,
|
|
provider: 'microsoft',
|
|
},
|
|
6: { id: 6, channel_type: INBOX_TYPES.EMAIL, provider: 'google' },
|
|
7: {
|
|
id: 7,
|
|
channel_type: INBOX_TYPES.WHATSAPP,
|
|
provider: 'default',
|
|
},
|
|
8: { id: 8, channel_type: INBOX_TYPES.TELEGRAM },
|
|
9: { id: 9, channel_type: INBOX_TYPES.LINE },
|
|
10: { id: 10, channel_type: INBOX_TYPES.WEB },
|
|
11: { id: 11, channel_type: INBOX_TYPES.API },
|
|
12: { id: 12, channel_type: INBOX_TYPES.SMS },
|
|
13: { id: 13, channel_type: INBOX_TYPES.INSTAGRAM },
|
|
14: {
|
|
id: 14,
|
|
channel_type: INBOX_TYPES.TWILIO,
|
|
voice_enabled: true,
|
|
},
|
|
15: { id: 15, channel_type: INBOX_TYPES.TIKTOK },
|
|
};
|
|
return inboxes[id] || null;
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Mock useMapGetter to return mock store getters
|
|
vi.mock('dashboard/composables/store', () => ({
|
|
useMapGetter: vi.fn(getter => {
|
|
if (getter === 'getSelectedChat') {
|
|
return { value: { inbox_id: 1 } };
|
|
}
|
|
if (getter === 'inboxes/getInboxById') {
|
|
return { value: mockStore.getters['inboxes/getInboxById'] };
|
|
}
|
|
return { value: null };
|
|
}),
|
|
}));
|
|
|
|
// Mock useCamelCase to return the data as-is for testing
|
|
vi.mock('dashboard/composables/useTransformKeys', () => ({
|
|
useCamelCase: vi.fn(data => ({
|
|
...data,
|
|
channelType: data?.channel_type,
|
|
})),
|
|
}));
|
|
|
|
describe('useInbox', () => {
|
|
const createTestComponent = inboxId =>
|
|
defineComponent({
|
|
setup() {
|
|
return useInbox(inboxId);
|
|
},
|
|
render() {
|
|
return h('div');
|
|
},
|
|
});
|
|
|
|
describe('with current chat context (no inboxId provided)', () => {
|
|
it('identifies WhatsApp Cloud channel correctly', () => {
|
|
const wrapper = mount(createTestComponent(), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
|
});
|
|
|
|
it('returns correct inbox object', () => {
|
|
const wrapper = mount(createTestComponent(), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.inbox).toEqual({
|
|
id: 1,
|
|
channel_type: INBOX_TYPES.WHATSAPP,
|
|
provider: 'whatsapp_cloud',
|
|
channelType: INBOX_TYPES.WHATSAPP,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('with explicit inboxId provided', () => {
|
|
it('identifies Facebook inbox correctly', () => {
|
|
const wrapper = mount(createTestComponent(2), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isAFacebookInbox).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
|
});
|
|
|
|
it('identifies Twilio SMS channel correctly', () => {
|
|
const wrapper = mount(createTestComponent(3), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
|
expect(wrapper.vm.isASmsInbox).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
|
});
|
|
|
|
it('identifies Twilio WhatsApp channel correctly', () => {
|
|
const wrapper = mount(createTestComponent(4), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
|
expect(wrapper.vm.isATwilioWhatsAppChannel).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
|
|
});
|
|
|
|
it('identifies Microsoft email inbox correctly', () => {
|
|
const wrapper = mount(createTestComponent(5), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
|
expect(wrapper.vm.isAMicrosoftInbox).toBe(true);
|
|
expect(wrapper.vm.isAGoogleInbox).toBe(false);
|
|
});
|
|
|
|
it('identifies Google email inbox correctly', () => {
|
|
const wrapper = mount(createTestComponent(6), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
|
expect(wrapper.vm.isAGoogleInbox).toBe(true);
|
|
expect(wrapper.vm.isAMicrosoftInbox).toBe(false);
|
|
});
|
|
|
|
it('identifies 360Dialog WhatsApp channel correctly', () => {
|
|
const wrapper = mount(createTestComponent(7), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
|
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(false);
|
|
});
|
|
|
|
it('identifies all other channel types correctly', () => {
|
|
// Test Telegram
|
|
let wrapper = mount(createTestComponent(8), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isATelegramChannel).toBe(true);
|
|
|
|
// Test Line
|
|
wrapper = mount(createTestComponent(9), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isALineChannel).toBe(true);
|
|
|
|
// Test Web Widget
|
|
wrapper = mount(createTestComponent(10), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
|
|
|
|
// Test API
|
|
wrapper = mount(createTestComponent(11), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isAPIInbox).toBe(true);
|
|
|
|
// Test SMS
|
|
wrapper = mount(createTestComponent(12), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isASmsInbox).toBe(true);
|
|
|
|
// Test Instagram
|
|
wrapper = mount(createTestComponent(13), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isAnInstagramChannel).toBe(true);
|
|
|
|
// Test Voice (Twilio with voice_enabled)
|
|
wrapper = mount(createTestComponent(14), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.voiceCallEnabled).toBe(true);
|
|
|
|
// Test Tiktok
|
|
wrapper = mount(createTestComponent(15), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
expect(wrapper.vm.isATiktokChannel).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles non-existent inbox ID gracefully', () => {
|
|
const wrapper = mount(createTestComponent(999), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
// useCamelCase still processes null data, so we get an object with channelType: undefined
|
|
expect(wrapper.vm.inbox).toEqual({ channelType: undefined });
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
|
expect(wrapper.vm.isAFacebookInbox).toBe(false);
|
|
});
|
|
|
|
it('handles inbox with no data correctly', () => {
|
|
// The mock will return null for non-existent IDs, but useCamelCase processes it
|
|
const wrapper = mount(createTestComponent(999), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
expect(wrapper.vm.inbox.channelType).toBeUndefined();
|
|
expect(wrapper.vm.isAWhatsAppChannel).toBe(false);
|
|
expect(wrapper.vm.isAFacebookInbox).toBe(false);
|
|
expect(wrapper.vm.isATelegramChannel).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('return object completeness', () => {
|
|
it('returns all expected properties', () => {
|
|
const wrapper = mount(createTestComponent(1), {
|
|
global: { plugins: [mockStore] },
|
|
});
|
|
|
|
const expectedProperties = [
|
|
'inbox',
|
|
'isAFacebookInbox',
|
|
'isALineChannel',
|
|
'isAPIInbox',
|
|
'isASmsInbox',
|
|
'isATelegramChannel',
|
|
'isATwilioChannel',
|
|
'isAWebWidgetInbox',
|
|
'isAWhatsAppChannel',
|
|
'isAMicrosoftInbox',
|
|
'isAGoogleInbox',
|
|
'isATwilioWhatsAppChannel',
|
|
'isAWhatsAppCloudChannel',
|
|
'is360DialogWhatsAppChannel',
|
|
'isAnEmailChannel',
|
|
'isAnInstagramChannel',
|
|
'isATiktokChannel',
|
|
'voiceCallEnabled',
|
|
'voiceCallProvider',
|
|
];
|
|
|
|
expectedProperties.forEach(prop => {
|
|
expect(wrapper.vm).toHaveProperty(prop);
|
|
});
|
|
});
|
|
});
|
|
});
|