Restructure omni services and add Chatwoot research snapshot

This commit is contained in:
Ruslan Bakiev
2026-02-21 11:11:27 +07:00
parent edea7a0034
commit b73babbbf6
7732 changed files with 978203 additions and 32 deletions

View File

@@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import { emitter } from 'shared/helpers/mitt';
import { useEmitter } from '../emitter';
import { defineComponent } from 'vue';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
on: vi.fn(),
off: vi.fn(),
},
}));
describe('useEmitter', () => {
const eventName = 'my-event';
const callback = vi.fn();
let wrapper;
const TestComponent = defineComponent({
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
template: '<div>Hello world</div>',
});
beforeEach(() => {
wrapper = shallowMount(TestComponent);
});
afterEach(() => {
vi.clearAllMocks();
});
it('should add an event listener on mount', () => {
expect(emitter.on).toHaveBeenCalledWith(eventName, callback);
});
it('should remove the event listener when the component is unmounted', async () => {
await wrapper.unmount();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
it('should return the cleanup function', () => {
const cleanup = wrapper.vm.cleanup;
expect(typeof cleanup).toBe('function');
cleanup();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
});

View File

@@ -0,0 +1,63 @@
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
export { allAgentsData };
export const formattedAgentsData = [
{
account_id: 0,
confirmed: true,
email: 'None',
id: 0,
name: 'None',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'Abraham',
confirmed: true,
email: 'abraham@chatwoot.com',
id: 5,
name: 'Abraham Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'online',
available_name: 'John K',
confirmed: true,
email: 'john@chatwoot.com',
id: 1,
name: 'John Kennady',
role: 'administrator',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Honey',
confirmed: true,
email: 'bee@chatwoot.com',
id: 4,
name: 'Honey Bee',
role: 'agent',
},
{
account_id: 1,
availability_status: 'busy',
available_name: 'Samuel K',
confirmed: true,
email: 'samuel@chatwoot.com',
id: 2,
name: 'Samuel Keta',
role: 'agent',
},
{
account_id: 1,
availability_status: 'offline',
available_name: 'James K',
confirmed: true,
email: 'james@chatwoot.com',
id: 3,
name: 'James Koti',
role: 'agent',
},
];

View File

@@ -0,0 +1,37 @@
export const summary = {
avg_first_response_time: '198.6666666666667',
avg_resolution_time: '208.3333333333333',
conversations_count: 5000,
incoming_messages_count: 5,
outgoing_messages_count: 3,
previous: {
avg_first_response_time: '89.0',
avg_resolution_time: '145.0',
conversations_count: 4,
incoming_messages_count: 5,
outgoing_messages_count: 4,
resolutions_count: 0,
},
resolutions_count: 3,
};
export const botSummary = {
bot_resolutions_count: 10,
bot_handoffs_count: 20,
previous: {
bot_resolutions_count: 8,
bot_handoffs_count: 5,
},
};
export const report = {
data: [
{ value: '0.00', timestamp: 1647541800, count: 0 },
{ value: '0.00', timestamp: 1647628200, count: 0 },
{ value: '0.00', timestamp: 1647714600, count: 0 },
{ value: '0.00', timestamp: 1647801000, count: 0 },
{ value: '0.01', timestamp: 1647887400, count: 4 },
{ value: '0.00', timestamp: 1647973800, count: 0 },
{ value: '0.00', timestamp: 1648060200, count: 0 },
],
};

View File

@@ -0,0 +1,50 @@
import { emitter } from 'shared/helpers/mitt';
import analyticsHelper from 'dashboard/helper/AnalyticsHelper';
import { useTrack, useAlert } from '../index';
vi.mock('shared/helpers/mitt', () => ({
emitter: {
emit: vi.fn(),
},
}));
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
describe('useTrack', () => {
it('should call analyticsHelper.track and return a function', () => {
const eventArgs = ['event-name', { some: 'data' }];
useTrack(...eventArgs);
expect(analyticsHelper.track).toHaveBeenCalledWith(...eventArgs);
});
});
describe('useAlert', () => {
it('should emit a newToastMessage event with the provided message and action', () => {
const message = 'Toast message';
const action = {
type: 'link',
to: '/app/accounts/1/conversations/1',
message: 'Navigate',
};
useAlert(message, action);
expect(emitter.emit).toHaveBeenCalledWith('newToastMessage', {
message,
action,
});
});
it('should emit a newToastMessage event with the provided message and no action if action is null', () => {
const message = 'Toast message';
useAlert(message);
expect(emitter.emit).toHaveBeenCalledWith('newToastMessage', {
message,
action: null,
});
});
});

View File

@@ -0,0 +1,117 @@
import { defineComponent, h } from 'vue';
import { createStore } from 'vuex';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAccount } from '../useAccount';
import { useRoute } from 'vue-router';
import { mount } from '@vue/test-utils';
const store = createStore({
modules: {
auth: {
namespaced: false,
getters: {
getCurrentAccountId: () => 1,
getCurrentUser: () => ({
accounts: [
{ id: 1, name: 'Chatwoot', role: 'administrator' },
{ id: 2, name: 'GitX', role: 'agent' },
],
}),
},
},
accounts: {
namespaced: true,
getters: {
getAccount: () => id => ({ id, name: 'Chatwoot' }),
},
},
},
});
const mountParams = {
global: {
plugins: [store],
},
};
vi.mock('vue-router');
describe('useAccount', () => {
beforeEach(() => {
useRoute.mockReturnValue({
params: {
accountId: '123',
},
});
});
const createComponent = () =>
defineComponent({
setup() {
return useAccount();
},
render() {
return h('div'); // Dummy render to satisfy mount
},
});
it('returns accountId as a computed property', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountId } = wrapper.vm;
expect(accountId).toBe(123);
});
it('generates account-scoped URLs correctly', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('settings/inbox/new');
expect(result).toBe('/app/accounts/123/settings/inbox/new');
});
it('handles URLs with leading slash', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('users');
expect(result).toBe('/app/accounts/123/users'); // Ensures no double slashes
});
it('handles empty URL', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedUrl } = wrapper.vm;
const result = accountScopedUrl('');
expect(result).toBe('/app/accounts/123/');
});
it('returns current account based on accountId', () => {
const wrapper = mount(createComponent(), mountParams);
const { currentAccount } = wrapper.vm;
expect(currentAccount).toEqual({ id: 123, name: 'Chatwoot' });
});
it('returns an account-scoped route', () => {
const wrapper = mount(createComponent(), mountParams);
const { accountScopedRoute } = wrapper.vm;
const result = accountScopedRoute('accountDetail', { userId: 456 }, {});
expect(result).toEqual({
name: 'accountDetail',
params: { accountId: 123, userId: 456 },
query: {},
});
});
it('returns route with correct params', () => {
const wrapper = mount(createComponent(), mountParams);
const { route } = wrapper.vm;
expect(route.params).toEqual({ accountId: '123' });
});
it('handles non-numeric accountId gracefully', async () => {
useRoute.mockReturnValueOnce({
params: { accountId: 'abc' },
});
const wrapper = mount(createComponent(), mountParams);
const { accountId } = wrapper.vm;
expect(accountId).toBeNaN(); // Handles invalid numeric conversion
});
});

View File

@@ -0,0 +1,52 @@
import { ref } from 'vue';
import { useAdmin } from '../useAdmin';
import { useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useAdmin', () => {
it('returns true if the current user is an administrator', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref('administrator'),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(true);
});
it('returns false if the current user is not an administrator', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref('user'),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is null', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(null),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is undefined', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(undefined),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
it('returns false if the current user role is an empty string', () => {
useStoreGetters.mockReturnValue({
getCurrentRole: ref(''),
});
const { isAdmin } = useAdmin();
expect(isAdmin.value).toBe(false);
});
});

View File

@@ -0,0 +1,102 @@
import { ref } from 'vue';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useAgentsList } from '../useAgentsList';
import { useMapGetter } from 'dashboard/composables/store';
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
import * as agentHelper from 'dashboard/helper/agentHelper';
// Mock vue-i18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: key => (key === 'AGENT_MGMT.MULTI_SELECTOR.LIST.NONE' ? 'None' : key),
}),
}));
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/agentHelper');
// Create a mock None agent
const mockNoneAgent = {
confirmed: true,
name: 'None',
id: 0,
role: 'agent',
account_id: 0,
email: 'None',
};
const mockUseMapGetter = (overrides = {}) => {
const defaultGetters = {
getCurrentUser: ref(allAgentsData[0]),
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
};
const mergedGetters = { ...defaultGetters, ...overrides };
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
};
describe('useAgentsList', () => {
beforeEach(() => {
vi.clearAllMocks();
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
formattedAgentsData.slice(1)
);
mockUseMapGetter();
});
it('returns agentsList and assignableAgents', () => {
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual(allAgentsData);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('includes None agent when includeNoneAgent is true', () => {
const { agentsList } = useAgentsList(true);
expect(agentsList.value[0]).toEqual(mockNoneAgent);
expect(agentsList.value.length).toBe(
formattedAgentsData.slice(1).length + 1
);
});
it('excludes None agent when includeNoneAgent is false', () => {
const { agentsList } = useAgentsList(false);
expect(agentsList.value[0].id).not.toBe(0);
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
it('handles missing inbox_id', () => {
mockUseMapGetter({
getSelectedChat: ref({ meta: { assignee: true } }),
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
});
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
const { agentsList, assignableAgents } = useAgentsList();
expect(assignableAgents.value).toEqual([]);
expect(agentsList.value).toEqual([mockNoneAgent]);
});
});

View File

@@ -0,0 +1,264 @@
import { useAutomation } from '../useAutomation';
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import * as automationHelper from 'dashboard/helper/automationHelper';
import {
customAttributes,
agents,
teams,
labels,
statusFilterOptions,
messageTypeOptions,
priorityOptions,
campaigns,
contacts,
inboxes,
languages,
countries,
slaPolicies,
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables');
vi.mock('vue-i18n');
vi.mock('dashboard/helper/automationHelper');
describe('useAutomation', () => {
beforeEach(() => {
useStoreGetters.mockReturnValue({
'attributes/getAttributes': { value: customAttributes },
'attributes/getAttributesByModel': {
value: model => {
return model === 'conversation_attribute'
? [{ id: 1, name: 'Conversation Attribute' }]
: [{ id: 2, name: 'Contact Attribute' }];
},
},
});
useMapGetter.mockImplementation(getter => {
const getterMap = {
'agents/getAgents': agents,
'campaigns/getAllCampaigns': campaigns,
'contacts/getContacts': contacts,
'inboxes/getInboxes': inboxes,
'labels/getLabels': labels,
'teams/getTeams': teams,
'sla/getSLA': slaPolicies,
};
return { value: getterMap[getter] };
});
useI18n.mockReturnValue({ t: key => key });
useAlert.mockReturnValue(vi.fn());
// Mock getConditionOptions for different types
automationHelper.getConditionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'status':
return statusFilterOptions;
case 'team_id':
return teams;
case 'assignee_id':
return agents;
case 'contact':
return contacts;
case 'inbox_id':
return inboxes;
case 'campaigns':
return campaigns;
case 'browser_language':
return languages;
case 'country_code':
return countries;
case 'message_type':
return messageTypeOptions;
case 'priority':
return priorityOptions;
default:
return [];
}
});
// Mock getActionOptions for different types
automationHelper.getActionOptions.mockImplementation(options => {
const { type } = options;
switch (type) {
case 'add_label':
return labels;
case 'assign_team':
return teams;
case 'assign_agent':
return agents;
case 'send_email_to_team':
return teams;
case 'send_message':
return [];
case 'add_sla':
return slaPolicies;
case 'change_priority':
return priorityOptions;
default:
return [];
}
});
});
it('initializes computed properties correctly', () => {
const {
agents: computedAgents,
campaigns: computedCampaigns,
contacts: computedContacts,
inboxes: computedInboxes,
labels: computedLabels,
teams: computedTeams,
slaPolicies: computedSlaPolicies,
} = useAutomation();
expect(computedAgents.value).toEqual(agents);
expect(computedCampaigns.value).toEqual(campaigns);
expect(computedContacts.value).toEqual(contacts);
expect(computedInboxes.value).toEqual(inboxes);
expect(computedLabels.value).toEqual(labels);
expect(computedTeams.value).toEqual(teams);
expect(computedSlaPolicies.value).toEqual(slaPolicies);
});
it('appends new condition and action correctly', () => {
const { appendNewCondition, appendNewAction, automation } = useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
appendNewCondition();
appendNewAction();
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
});
it('removes filter and action correctly', () => {
const { removeFilter, removeAction, automation } = useAutomation();
automation.value = {
conditions: [{ id: 1 }, { id: 2 }],
actions: [{ id: 1 }, { id: 2 }],
};
removeFilter(0);
removeAction(0);
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
expect(automation.value.conditions[0].id).toBe(2);
expect(automation.value.actions[0].id).toBe(2);
});
it('resets filter and action correctly', () => {
const { resetFilter, resetAction, automation, automationTypes } =
useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: 'open',
},
],
actions: [{ action_name: 'assign_agent', action_params: [1] }],
};
automationTypes.message_created = {
conditions: [
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
],
};
resetFilter(0, automation.value.conditions[0]);
resetAction(0);
expect(automation.value.conditions[0].filter_operator).toBe('not_equal_to');
expect(automation.value.conditions[0].values).toBe('');
expect(automation.value.actions[0].action_params).toEqual([]);
});
it('manifests custom attributes correctly', () => {
const { manifestCustomAttributes, automationTypes } = useAutomation();
automationTypes.message_created = { conditions: [] };
automationTypes.conversation_created = { conditions: [] };
automationTypes.conversation_updated = { conditions: [] };
automationTypes.conversation_opened = { conditions: [] };
automationTypes.conversation_resolved = { conditions: [] };
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
automationHelper.generateCustomAttributes.mockReturnValue([]);
manifestCustomAttributes();
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
2
);
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
Object.values(automationTypes).forEach(type => {
expect(type.conditions).toHaveLength(0);
});
});
it('gets condition dropdown values correctly', () => {
const { getConditionDropdownValues } = useAutomation();
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
expect(getConditionDropdownValues('team_id')).toEqual(teams);
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
expect(getConditionDropdownValues('contact')).toEqual(contacts);
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
expect(getConditionDropdownValues('country_code')).toEqual(countries);
expect(getConditionDropdownValues('message_type')).toEqual(
messageTypeOptions
);
expect(getConditionDropdownValues('priority')).toEqual(priorityOptions);
});
it('gets action dropdown values correctly', () => {
const { getActionDropdownValues } = useAutomation();
expect(getActionDropdownValues('add_label')).toEqual(labels);
expect(getActionDropdownValues('assign_team')).toEqual(teams);
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
expect(getActionDropdownValues('send_message')).toEqual([]);
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
expect(getActionDropdownValues('change_priority')).toEqual(priorityOptions);
});
it('handles event change correctly', () => {
const { onEventChange, automation } = useAutomation();
automation.value = {
event_name: 'message_created',
conditions: [],
actions: [],
};
automationHelper.getDefaultConditions.mockReturnValue([{}]);
automationHelper.getDefaultActions.mockReturnValue([{}]);
onEventChange();
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
'message_created'
);
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
expect(automation.value.conditions).toHaveLength(1);
expect(automation.value.actions).toHaveLength(1);
});
});

View File

@@ -0,0 +1,172 @@
import { useCaptain } from '../useCaptain';
import {
useFunctionGetter,
useMapGetter,
useStore,
} from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
import { useConfig } from 'dashboard/composables/useConfig';
import { useI18n } from 'vue-i18n';
import TasksAPI from 'dashboard/api/captain/tasks';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/useConfig');
vi.mock('vue-i18n');
vi.mock('dashboard/api/captain/tasks');
vi.mock('dashboard/helper/AnalyticsHelper/index', async importOriginal => {
const actual = await importOriginal();
actual.default = {
track: vi.fn(),
};
return actual;
});
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
CAPTAIN_EVENTS: {
TEST_EVENT: 'captain_test_event',
},
}));
describe('useCaptain', () => {
const mockStore = {
dispatch: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
useStore.mockReturnValue(mockStore);
useFunctionGetter.mockReturnValue({ value: 'Draft message' });
useMapGetter.mockImplementation(getter => {
const mockValues = {
'accounts/getUIFlags': { isFetchingLimits: false },
getSelectedChat: { id: '123' },
'draftMessages/getReplyEditorMode': 'reply',
};
return { value: mockValues[getter] };
});
useI18n.mockReturnValue({ t: vi.fn() });
useAccount.mockReturnValue({
isCloudFeatureEnabled: vi.fn().mockReturnValue(true),
currentAccount: { value: { limits: { captain: {} } } },
});
useConfig.mockReturnValue({
isEnterprise: false,
});
});
it('initializes computed properties correctly', async () => {
const { captainEnabled, captainTasksEnabled, currentChat, draftMessage } =
useCaptain();
expect(captainEnabled.value).toBe(true);
expect(captainTasksEnabled.value).toBe(true);
expect(currentChat.value).toEqual({ id: '123' });
expect(draftMessage.value).toBe('Draft message');
});
it('rewrites content', async () => {
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten content', follow_up_context: { id: 'ctx1' } },
});
const { rewriteContent } = useCaptain();
const result = await rewriteContent('Original content', 'improve', {});
expect(TasksAPI.rewrite).toHaveBeenCalledWith(
{
content: 'Original content',
operation: 'improve',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Rewritten content',
followUpContext: { id: 'ctx1' },
});
});
it('summarizes conversation', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary', follow_up_context: { id: 'ctx2' } },
});
const { summarizeConversation } = useCaptain();
const result = await summarizeConversation({});
expect(TasksAPI.summarize).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Summary',
followUpContext: { id: 'ctx2' },
});
});
it('gets reply suggestion', async () => {
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply suggestion', follow_up_context: { id: 'ctx3' } },
});
const { getReplySuggestion } = useCaptain();
const result = await getReplySuggestion({});
expect(TasksAPI.replySuggestion).toHaveBeenCalledWith('123', undefined);
expect(result).toEqual({
message: 'Reply suggestion',
followUpContext: { id: 'ctx3' },
});
});
it('sends follow-up message', async () => {
TasksAPI.followUp.mockResolvedValue({
data: {
message: 'Follow-up response',
follow_up_context: { id: 'ctx4' },
},
});
const { followUp } = useCaptain();
const result = await followUp({
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
});
expect(TasksAPI.followUp).toHaveBeenCalledWith(
{
followUpContext: { id: 'ctx3' },
message: 'Make it shorter',
conversationId: '123',
},
undefined
);
expect(result).toEqual({
message: 'Follow-up response',
followUpContext: { id: 'ctx4' },
});
});
it('processes event and routes to correct method', async () => {
TasksAPI.summarize.mockResolvedValue({
data: { message: 'Summary' },
});
TasksAPI.replySuggestion.mockResolvedValue({
data: { message: 'Reply' },
});
TasksAPI.rewrite.mockResolvedValue({
data: { message: 'Rewritten' },
});
const { processEvent } = useCaptain();
// Test summarize
await processEvent('summarize', '', {});
expect(TasksAPI.summarize).toHaveBeenCalled();
// Test reply_suggestion
await processEvent('reply_suggestion', '', {});
expect(TasksAPI.replySuggestion).toHaveBeenCalled();
// Test rewrite (improve)
await processEvent('improve', 'content', {});
expect(TasksAPI.rewrite).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,51 @@
import { useConfig } from '../useConfig';
describe('useConfig', () => {
const originalChatwootConfig = window.chatwootConfig;
beforeEach(() => {
window.chatwootConfig = {
hostURL: 'https://example.com',
vapidPublicKey: 'vapid-key',
enabledLanguages: ['en', 'fr'],
isEnterprise: 'true',
enterprisePlanName: 'enterprise',
};
});
afterEach(() => {
window.chatwootConfig = originalChatwootConfig;
});
it('returns the correct configuration values', () => {
const config = useConfig();
expect(config.hostURL).toBe('https://example.com');
expect(config.vapidPublicKey).toBe('vapid-key');
expect(config.enabledLanguages).toEqual(['en', 'fr']);
expect(config.isEnterprise).toBe(true);
expect(config.enterprisePlanName).toBe('enterprise');
});
it('handles missing configuration values', () => {
window.chatwootConfig = {};
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
it('handles undefined window.chatwootConfig', () => {
window.chatwootConfig = undefined;
const config = useConfig();
expect(config.hostURL).toBeUndefined();
expect(config.vapidPublicKey).toBeUndefined();
expect(config.enabledLanguages).toBeUndefined();
expect(config.isEnterprise).toBe(false);
expect(config.enterprisePlanName).toBeUndefined();
});
});

View File

@@ -0,0 +1,87 @@
import { useConversationLabels } from '../useConversationLabels';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store');
describe('useConversationLabels', () => {
let store;
let getters;
beforeEach(() => {
store = {
getters: {
'conversationLabels/getConversationLabels': vi.fn(),
},
dispatch: vi.fn(),
};
getters = {
getSelectedChat: { value: { id: 1 } },
'labels/getLabels': {
value: [
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
{ id: 3, title: 'Label 3' },
],
},
};
useStore.mockReturnValue(store);
useStoreGetters.mockReturnValue(getters);
});
it('should return the correct computed properties', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
useConversationLabels();
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
expect(activeLabels.value).toEqual([
{ id: 1, title: 'Label 1' },
{ id: 2, title: 'Label 2' },
]);
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
});
it('should update labels correctly', async () => {
const { onUpdateLabels } = useConversationLabels();
await onUpdateLabels(['Label 1', 'Label 3']);
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 3'],
});
});
it('should add a label to the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
]);
const { addLabelToConversation } = useConversationLabels();
addLabelToConversation({ title: 'Label 2' });
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1', 'Label 2'],
});
});
it('should remove a label from the conversation', () => {
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
'Label 1',
'Label 2',
]);
const { removeLabelFromConversation } = useConversationLabels();
removeLabelFromConversation('Label 2');
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
conversationId: 1,
labels: ['Label 1'],
});
});
});

View File

@@ -0,0 +1,348 @@
import { useConversationRequiredAttributes } from '../useConversationRequiredAttributes';
import { useMapGetter } from 'dashboard/composables/store';
import { useAccount } from 'dashboard/composables/useAccount';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useAccount');
const defaultAttributes = [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'category',
attributeDisplayName: 'Category',
attributeDisplayType: 'text',
attributeValues: [],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
];
describe('useConversationRequiredAttributes', () => {
beforeEach(() => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => true };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: defaultAttributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: [
'priority',
'category',
'is_urgent',
],
},
},
},
accountId: { value: 1 },
});
});
const setupMocks = (
requiredAttributes = ['priority', 'category', 'is_urgent'],
{ attributes = defaultAttributes, featureEnabled = true } = {}
) => {
useMapGetter.mockImplementation(getter => {
if (getter === 'accounts/isFeatureEnabledonAccount') {
return { value: () => featureEnabled };
}
if (getter === 'attributes/getConversationAttributes') {
return { value: attributes };
}
return { value: null };
});
useAccount.mockReturnValue({
currentAccount: {
value: {
settings: {
conversation_required_attributes: requiredAttributes,
},
},
},
accountId: { value: 1 },
});
};
describe('requiredAttributeKeys', () => {
it('should return required attribute keys from account settings', () => {
setupMocks();
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([
'priority',
'category',
'is_urgent',
]);
});
it('should return empty array when no required attributes configured', () => {
setupMocks([]);
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
it('should return empty array when account settings is null', () => {
setupMocks([], { attributes: [] });
useAccount.mockReturnValue({
currentAccount: { value: { settings: null } },
accountId: { value: 1 },
});
const { requiredAttributeKeys } = useConversationRequiredAttributes();
expect(requiredAttributeKeys.value).toEqual([]);
});
});
describe('requiredAttributes', () => {
it('should return full attribute definitions for required attributes only', () => {
setupMocks();
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(3);
expect(requiredAttributes.value[0]).toEqual({
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
value: 'priority',
label: 'Priority',
type: 'list',
});
});
it('should filter out deleted attributes that no longer exist', () => {
// Mock with only 2 attributes available but 3 required
setupMocks(['priority', 'category', 'is_urgent'], {
attributes: [
{
attributeKey: 'priority',
attributeDisplayName: 'Priority',
attributeDisplayType: 'list',
attributeValues: ['High', 'Medium', 'Low'],
},
{
attributeKey: 'is_urgent',
attributeDisplayName: 'Is Urgent',
attributeDisplayType: 'checkbox',
attributeValues: [],
},
],
});
const { requiredAttributes } = useConversationRequiredAttributes();
expect(requiredAttributes.value).toHaveLength(2);
expect(requiredAttributes.value.map(attr => attr.value)).toEqual([
'priority',
'is_urgent',
]);
});
});
describe('checkMissingAttributes', () => {
beforeEach(() => {
setupMocks();
});
it('should return no missing when all attributes are filled', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
expect(result.all).toHaveLength(3);
});
it('should detect missing text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
is_urgent: true,
// category is missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should detect empty string values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: '', // empty string
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
it('should consider checkbox attribute present when value is true', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should consider checkbox attribute present when value is false', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: false, // false is still considered "filled"
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should detect missing checkbox when key does not exist', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
// is_urgent key is completely missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('is_urgent');
expect(result.missing[0].type).toBe('checkbox');
});
it('should handle falsy values correctly for non-checkbox attributes', () => {
setupMocks(['score', 'status_flag'], {
attributes: [
{
attributeKey: 'score',
attributeDisplayName: 'Score',
attributeDisplayType: 'number',
attributeValues: [],
},
{
attributeKey: 'status_flag',
attributeDisplayName: 'Status Flag',
attributeDisplayType: 'text',
attributeValues: [],
},
],
});
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
score: 0, // zero should be considered valid, not missing
status_flag: false, // false should be considered valid, not missing
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle null values as missing for text attributes', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: null, // null should be missing for text attribute
is_urgent: true, // checkbox is present
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing).toHaveLength(1);
expect(result.missing[0].value).toBe('category');
});
it('should consider undefined checkbox values as present when key exists', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: 'Bug Report',
is_urgent: undefined, // key exists but value is undefined - still considered "filled" for checkbox
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should return no missing when no attributes are required', () => {
setupMocks([]); // No required attributes
const { checkMissingAttributes } = useConversationRequiredAttributes();
const result = checkMissingAttributes({});
expect(result.hasMissing).toBe(false);
expect(result.missing).toEqual([]);
});
it('should handle whitespace-only values as missing', () => {
const { checkMissingAttributes } = useConversationRequiredAttributes();
const customAttributes = {
priority: 'High',
category: ' ', // whitespace only
is_urgent: true,
};
const result = checkMissingAttributes(customAttributes);
expect(result.hasMissing).toBe(true);
expect(result.missing[0].value).toBe('category');
});
});
});

View File

@@ -0,0 +1,101 @@
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
import {
LAYOUT_QWERTY,
LAYOUT_QWERTZ,
LAYOUT_AZERTY,
} from 'shared/helpers/KeyboardHelpers';
describe('useDetectKeyboardLayout', () => {
beforeEach(() => {
window.cw_keyboard_layout = null;
});
it('returns cached layout if available', async () => {
window.cw_keyboard_layout = LAYOUT_QWERTY;
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
});
it('should detect QWERTZ layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'z'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTZ);
});
it('should detect AZERTY layout using modern method', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'a'],
['KeyW', 'z'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_AZERTY);
});
it('should use legacy method if navigator.keyboard is not available', async () => {
navigator.keyboard = undefined;
const layout = await useDetectKeyboardLayout();
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
});
it('should cache the detected layout', async () => {
navigator.keyboard = {
getLayoutMap: vi.fn().mockResolvedValue(
new Map([
['KeyQ', 'q'],
['KeyW', 'w'],
['KeyE', 'e'],
['KeyR', 'r'],
['KeyT', 't'],
['KeyY', 'y'],
])
),
};
const layout = await useDetectKeyboardLayout();
expect(layout).toBe(LAYOUT_QWERTY);
const layoutAgain = await useDetectKeyboardLayout();
expect(layoutAgain).toBe(LAYOUT_QWERTY);
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,183 @@
import { useFileUpload } from '../useFileUpload';
import { useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import { DirectUpload } from 'activestorage';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { getMaxUploadSizeByChannel } from '@chatwoot/utils';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
vi.mock('activestorage');
vi.mock('shared/helpers/FileHelper', () => ({
checkFileSizeLimit: vi.fn(),
resolveMaximumFileUploadSize: vi.fn(value => Number(value) || 40),
DEFAULT_MAXIMUM_FILE_UPLOAD_SIZE: 40,
}));
vi.mock('@chatwoot/utils');
describe('useFileUpload', () => {
const mockAttachFile = vi.fn();
const mockTranslate = vi.fn();
const mockFile = {
file: new File(['test'], 'test.jpg', { type: 'image/jpeg' }),
};
const inbox = {
channel_type: 'Channel::WhatsApp',
medium: 'whatsapp',
};
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: true, maximumFileUploadSize: 40 },
},
};
return getterMap[getter];
});
useI18n.mockReturnValue({ t: mockTranslate });
checkFileSizeLimit.mockReturnValue(true);
getMaxUploadSizeByChannel.mockReturnValue(25); // default max size MB for tests
});
it('handles direct file upload when direct uploads enabled', () => {
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
const mockBlob = { signed_id: 'test-blob' };
DirectUpload.mockImplementation(() => ({
create: callback => callback(null, mockBlob),
}));
onFileUpload(mockFile);
// size rules called with inbox + mime
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
// size check called with max from helper
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(DirectUpload).toHaveBeenCalledWith(
mockFile.file,
'/api/v1/accounts/123/conversations/456/direct_uploads',
expect.any(Object)
);
expect(mockAttachFile).toHaveBeenCalledWith({
file: mockFile,
blob: mockBlob,
});
});
it('handles indirect file upload when direct upload disabled', () => {
useMapGetter.mockImplementation(getter => {
const getterMap = {
getCurrentAccountId: { value: '123' },
getCurrentUser: { value: { access_token: 'test-token' } },
getSelectedChat: { value: { id: '456' } },
'globalConfig/get': {
value: { directUploadsEnabled: false, maximumFileUploadSize: 40 },
},
};
return getterMap[getter];
});
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(DirectUpload).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).toHaveBeenCalled();
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 25);
expect(mockAttachFile).toHaveBeenCalledWith({ file: mockFile });
});
it('shows alert when file size exceeds limit', () => {
checkFileSizeLimit.mockReturnValue(false);
mockTranslate.mockReturnValue('File size exceeds limit');
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith('File size exceeds limit');
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('uses per-mime limits from helper', () => {
getMaxUploadSizeByChannel.mockImplementation(({ mime }) =>
mime.startsWith('image/') ? 10 : 50
);
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
DirectUpload.mockImplementation(() => ({
create: cb => cb(null, { signed_id: 'blob' }),
}));
onFileUpload(mockFile);
expect(getMaxUploadSizeByChannel).toHaveBeenCalledWith({
channelType: inbox.channel_type,
medium: inbox.medium,
mime: 'image/jpeg',
});
expect(checkFileSizeLimit).toHaveBeenCalledWith(mockFile, 10);
});
it('handles direct upload errors', () => {
const mockError = 'Upload failed';
DirectUpload.mockImplementation(() => ({
create: callback => callback(mockError, null),
}));
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(mockFile);
expect(useAlert).toHaveBeenCalledWith(mockError);
expect(mockAttachFile).not.toHaveBeenCalled();
});
it('does nothing when file is null', () => {
const { onFileUpload } = useFileUpload({
inbox,
attachFile: mockAttachFile,
});
onFileUpload(null);
expect(checkFileSizeLimit).not.toHaveBeenCalled();
expect(getMaxUploadSizeByChannel).not.toHaveBeenCalled();
expect(mockAttachFile).not.toHaveBeenCalled();
expect(useAlert).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,165 @@
import { ref } from 'vue';
import { useFontSize } from '../useFontSize';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
// Mock dependencies
vi.mock('dashboard/composables/useUISettings');
vi.mock('dashboard/composables', () => ({
useAlert: vi.fn(message => message),
}));
vi.mock('vue-i18n');
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn(cb => cb());
describe('useFontSize', () => {
const mockUISettings = ref({
font_size: '16px',
});
const mockUpdateUISettings = vi.fn().mockResolvedValue(undefined);
const mockTranslate = vi.fn(key => key);
beforeEach(() => {
vi.clearAllMocks();
// Setup mocks
useUISettings.mockReturnValue({
uiSettings: mockUISettings,
updateUISettings: mockUpdateUISettings,
});
useI18n.mockReturnValue({
t: mockTranslate,
});
// Reset DOM state
document.documentElement.style.removeProperty('font-size');
// Reset mockUISettings to default
mockUISettings.value = { font_size: '16px' };
});
it('returns fontSizeOptions with correct structure', () => {
const { fontSizeOptions } = useFontSize();
expect(fontSizeOptions.value).toHaveLength(5);
expect(fontSizeOptions.value[0]).toHaveProperty('value');
expect(fontSizeOptions.value[0]).toHaveProperty('label');
// Check specific options
expect(
fontSizeOptions.value.find(option => option.value === '16px')
).toEqual({
value: '16px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT',
});
expect(
fontSizeOptions.value.find(option => option.value === '14px')
).toEqual({
value: '14px',
label:
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER',
});
});
it('returns currentFontSize from UI settings', () => {
const { currentFontSize } = useFontSize();
expect(currentFontSize.value).toBe('16px');
mockUISettings.value.font_size = '18px';
expect(currentFontSize.value).toBe('18px');
});
it('applies font size to document root correctly based on pixel values', () => {
const { applyFontSize } = useFontSize();
applyFontSize('18px');
expect(document.documentElement.style.fontSize).toBe('18px');
applyFontSize('14px');
expect(document.documentElement.style.fontSize).toBe('14px');
applyFontSize('16px');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('updates UI settings and applies font size', async () => {
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(mockUpdateUISettings).toHaveBeenCalledWith({ font_size: '20px' });
expect(document.documentElement.style.fontSize).toBe('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_SUCCESS'
);
});
it('shows error alert when update fails', async () => {
mockUpdateUISettings.mockRejectedValueOnce(new Error('Update failed'));
const { updateFontSize } = useFontSize();
await updateFontSize('20px');
expect(useAlert).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.UPDATE_ERROR'
);
});
it('handles unknown font size values gracefully', () => {
const { applyFontSize } = useFontSize();
// Should not throw an error and should apply the default font size
applyFontSize('unknown-size');
expect(document.documentElement.style.fontSize).toBe('16px');
});
it('watches for UI settings changes and applies font size', async () => {
useFontSize();
// Initial font size should now be 16px instead of empty
expect(document.documentElement.style.fontSize).toBe('16px');
// Update UI settings
mockUISettings.value = { font_size: '18px' };
// Wait for next tick to let watchers fire
await Promise.resolve();
expect(document.documentElement.style.fontSize).toBe('18px');
});
it('translates font size option labels correctly', () => {
// Set up specific translation mapping
mockTranslate.mockImplementation(key => {
const translations = {
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER':
'Smaller',
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT':
'Default',
};
return translations[key] || key;
});
const { fontSizeOptions } = useFontSize();
// Check that translation is applied
expect(
fontSizeOptions.value.find(option => option.value === '14px').label
).toBe('Smaller');
expect(
fontSizeOptions.value.find(option => option.value === '16px').label
).toBe('Default');
// Verify translation function was called with correct keys
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.SMALLER'
);
expect(mockTranslate).toHaveBeenCalledWith(
'PROFILE_SETTINGS.FORM.INTERFACE_SECTION.FONT_SIZE.OPTIONS.DEFAULT'
);
});
});

View File

@@ -0,0 +1,141 @@
import { ref } from 'vue';
import { useImageZoom } from 'dashboard/composables/useImageZoom';
describe('useImageZoom', () => {
let imageRef;
beforeEach(() => {
// Mock imageRef element with getBoundingClientRect method
imageRef = ref({
getBoundingClientRect: () => ({
left: 100,
top: 100,
width: 200,
height: 200,
}),
});
});
it('should initialize with default values', () => {
const { zoomScale, imgTransformOriginPoint, activeImageRotation } =
useImageZoom(imageRef);
expect(zoomScale.value).toBe(1);
expect(imgTransformOriginPoint.value).toBe('center center');
expect(activeImageRotation.value).toBe(0);
});
it('should update zoom scale when onZoom is called', () => {
const { zoomScale, onZoom } = useImageZoom(imageRef);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
// Should respect max zoom level
onZoom(10);
expect(zoomScale.value).toBe(3);
// Should respect min zoom level
onZoom(-10);
expect(zoomScale.value).toBe(1);
});
it('should update rotation when onRotate is called', () => {
const { activeImageRotation, onRotate } = useImageZoom(imageRef);
onRotate('clockwise');
expect(activeImageRotation.value).toBe(90);
onRotate('counter-clockwise');
expect(activeImageRotation.value).toBe(0);
// Test full rotation reset
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
onRotate('clockwise');
// After 360 degrees, it should reset and add the new rotation
expect(activeImageRotation.value).toBe(90);
});
it('should reset zoom and rotation', () => {
const {
zoomScale,
activeImageRotation,
onZoom,
onRotate,
resetZoomAndRotation,
} = useImageZoom(imageRef);
onZoom(0.5);
onRotate('clockwise');
expect(zoomScale.value).toBe(1); // Rotation resets zoom
expect(activeImageRotation.value).toBe(90);
onZoom(0.5);
expect(zoomScale.value).toBe(1.5);
resetZoomAndRotation();
expect(zoomScale.value).toBe(1);
expect(activeImageRotation.value).toBe(0);
});
it('should handle double click zoom', () => {
const { zoomScale, onDoubleClickZoomImage } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
preventDefault: vi.fn(),
};
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(3); // Max zoom
expect(event.preventDefault).toHaveBeenCalled();
onDoubleClickZoomImage(event);
expect(zoomScale.value).toBe(1); // Min zoom
});
it('should handle wheel zoom', () => {
const { zoomScale, onWheelImageZoom } = useImageZoom(imageRef);
// Mock event
const event = {
clientX: 150,
clientY: 150,
deltaY: -10, // Zoom in
preventDefault: vi.fn(),
};
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1.2);
expect(event.preventDefault).toHaveBeenCalled();
// Zoom out
event.deltaY = 10;
onWheelImageZoom(event);
expect(zoomScale.value).toBe(1);
});
it('should correctly compute zoom origin', () => {
const { getZoomOrigin } = useImageZoom(imageRef);
// Test center point
const centerOrigin = getZoomOrigin(200, 200);
expect(centerOrigin.x).toBeCloseTo(50);
expect(centerOrigin.y).toBeCloseTo(50);
// Test top-left corner
const topLeftOrigin = getZoomOrigin(100, 100);
expect(topLeftOrigin.x).toBeCloseTo(0);
expect(topLeftOrigin.y).toBeCloseTo(0);
// Test bottom-right corner
const bottomRightOrigin = getZoomOrigin(300, 300);
expect(bottomRightOrigin.x).toBeCloseTo(100);
expect(bottomRightOrigin.y).toBeCloseTo(100);
});
});

View File

@@ -0,0 +1,37 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useImpersonation } from '../useImpersonation';
vi.mock('shared/helpers/sessionStorage', () => ({
__esModule: true,
default: {
get: vi.fn(),
set: vi.fn(),
},
}));
import SessionStorage from 'shared/helpers/sessionStorage';
import { SESSION_STORAGE_KEYS } from 'dashboard/constants/sessionStorage';
describe('useImpersonation', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should return true if impersonation flag is set in session storage', () => {
SessionStorage.get.mockReturnValue(true);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(true);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
it('should return false if impersonation flag is not set in session storage', () => {
SessionStorage.get.mockReturnValue(false);
const { isImpersonating } = useImpersonation();
expect(isImpersonating.value).toBe(false);
expect(SessionStorage.get).toHaveBeenCalledWith(
SESSION_STORAGE_KEYS.IMPERSONATION_USER
);
});
});

View File

@@ -0,0 +1,285 @@
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.VOICE },
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
wrapper = mount(createTestComponent(14), {
global: { plugins: [mockStore] },
});
expect(wrapper.vm.isAVoiceChannel).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',
'isAVoiceChannel',
];
expectedProperties.forEach(prop => {
expect(wrapper.vm).toHaveProperty(prop);
});
});
});
});

View File

@@ -0,0 +1,56 @@
import { useIntegrationHook } from '../useIntegrationHook';
import { useMapGetter } from 'dashboard/composables/store';
import { nextTick } from 'vue';
vi.mock('dashboard/composables/store');
describe('useIntegrationHook', () => {
let integrationGetter;
beforeEach(() => {
integrationGetter = vi.fn();
useMapGetter.mockReturnValue({ value: integrationGetter });
});
it('should return the correct computed properties', async () => {
const mockIntegration = {
id: 1,
hook_type: 'inbox',
hooks: ['hook1', 'hook2'],
allow_multiple_hooks: true,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(1);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('multiple');
expect(hook.isIntegrationMultiple.value).toBe(true);
expect(hook.isIntegrationSingle.value).toBe(false);
expect(hook.isHookTypeInbox.value).toBe(true);
expect(hook.hasConnectedHooks.value).toBe(true);
});
it('should handle single integration type correctly', async () => {
const mockIntegration = {
id: 2,
hook_type: 'channel',
hooks: [],
allow_multiple_hooks: false,
};
integrationGetter.mockReturnValue(mockIntegration);
const hook = useIntegrationHook(2);
await nextTick();
expect(hook.integration.value).toEqual(mockIntegration);
expect(hook.integrationType.value).toBe('single');
expect(hook.isIntegrationMultiple.value).toBe(false);
expect(hook.isIntegrationSingle.value).toBe(true);
expect(hook.isHookTypeInbox.value).toBe(false);
expect(hook.hasConnectedHooks.value).toBe(false);
});
});

View File

@@ -0,0 +1,26 @@
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
describe('useKeyboardEvents', () => {
it('should be defined', () => {
expect(useKeyboardEvents).toBeDefined();
});
it('should return a function', () => {
expect(useKeyboardEvents).toBeInstanceOf(Function);
});
it('should set up listeners on mount and remove them on unmount', async () => {
const events = {
'ALT+KeyL': () => {},
};
const mountedMock = vi.fn();
const unmountedMock = vi.fn();
useKeyboardEvents(events);
mountedMock();
unmountedMock();
expect(mountedMock).toHaveBeenCalled();
expect(unmountedMock).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,260 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ref } from 'vue';
import { useKeyboardNavigableList } from '../useKeyboardNavigableList';
import { useKeyboardEvents } from '../useKeyboardEvents';
// Mock the useKeyboardEvents function
vi.mock('../useKeyboardEvents', () => ({
useKeyboardEvents: vi.fn(),
}));
describe('useKeyboardNavigableList', () => {
let items;
let onSelect;
let adjustScroll;
let selectedIndex;
const createMockEvent = () => ({ preventDefault: vi.fn() });
beforeEach(() => {
items = ref(['item1', 'item2', 'item3']);
onSelect = vi.fn();
adjustScroll = vi.fn();
selectedIndex = ref(0);
vi.clearAllMocks();
});
it('should return moveSelectionUp and moveSelectionDown functions', () => {
const result = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(result).toHaveProperty('moveSelectionUp');
expect(result).toHaveProperty('moveSelectionDown');
expect(typeof result.moveSelectionUp).toBe('function');
expect(typeof result.moveSelectionDown).toBe('function');
});
it('should move selection up correctly', () => {
const { moveSelectionUp } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
moveSelectionUp();
expect(selectedIndex.value).toBe(1);
moveSelectionUp();
expect(selectedIndex.value).toBe(0);
moveSelectionUp();
expect(selectedIndex.value).toBe(2);
});
it('should move selection down correctly', () => {
const { moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
moveSelectionDown();
expect(selectedIndex.value).toBe(2);
moveSelectionDown();
expect(selectedIndex.value).toBe(0);
moveSelectionDown();
expect(selectedIndex.value).toBe(1);
});
it('should call adjustScroll after moving selection', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
expect(adjustScroll).toHaveBeenCalledTimes(1);
moveSelectionDown();
expect(adjustScroll).toHaveBeenCalledTimes(2);
});
it('should include Enter key handler when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).toHaveProperty('Enter');
expect(keyboardEvents.Enter.allowOnFocusedInput).toBe(true);
});
it('should not include Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEvents).not.toHaveProperty('Enter');
});
it('should not trigger onSelect when items are empty', () => {
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
items: ref([]),
onSelect,
adjustScroll,
selectedIndex,
});
moveSelectionUp();
moveSelectionDown();
expect(onSelect).not.toHaveBeenCalled();
});
it('should call useKeyboardEvents with correct parameters', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
});
// Keyboard event handlers
it('should handle ArrowUp key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowUp.action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyP', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyP'].action(mockEvent);
expect(selectedIndex.value).toBe(2);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle ArrowDown key', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.ArrowDown.action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Control+KeyN', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents['Control+KeyN'].action(mockEvent);
expect(selectedIndex.value).toBe(1);
expect(adjustScroll).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should handle Enter key when onSelect is provided', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const mockEvent = createMockEvent();
keyboardEvents.Enter.action(mockEvent);
expect(onSelect).toHaveBeenCalled();
expect(mockEvent.preventDefault).toHaveBeenCalled();
});
it('should not have Enter key handler when onSelect is not provided', () => {
useKeyboardNavigableList({
items,
adjustScroll,
selectedIndex,
});
const keyboardEventsWithoutSelect = useKeyboardEvents.mock.calls[0][0];
expect(keyboardEventsWithoutSelect).not.toHaveProperty('Enter');
});
it('should set allowOnFocusedInput to true for all key handlers', () => {
useKeyboardNavigableList({
items,
onSelect,
adjustScroll,
selectedIndex,
});
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
const keyHandlers = [
'ArrowUp',
'Control+KeyP',
'ArrowDown',
'Control+KeyN',
'Enter',
];
keyHandlers.forEach(key => {
if (keyboardEvents[key]) {
expect(keyboardEvents[key].allowOnFocusedInput).toBe(true);
}
});
});
});

View File

@@ -0,0 +1,180 @@
import { describe, it, expect, vi } from 'vitest';
import { useMacros } from '../useMacros';
import { useStoreGetters } from 'dashboard/composables/store';
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
vi.mock('dashboard/composables/store');
vi.mock('dashboard/helper/automationHelper.js');
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: key => key }),
}));
describe('useMacros', () => {
const mockLabels = [
{
id: 6,
title: 'sales',
description: 'sales team',
color: '#8EA20F',
show_on_sidebar: true,
},
{
id: 2,
title: 'billing',
description: 'billing',
color: '#4077DA',
show_on_sidebar: true,
},
{
id: 1,
title: 'snoozed',
description: 'Items marked for later',
color: '#D12F42',
show_on_sidebar: true,
},
{
id: 5,
title: 'mobile-app',
description: 'tech team',
color: '#2DB1CC',
show_on_sidebar: true,
},
{
id: 14,
title: 'human-resources-department-with-long-title',
description: 'Test',
color: '#FF6E09',
show_on_sidebar: true,
},
{
id: 22,
title: 'priority',
description: 'For important sales leads',
color: '#7E7CED',
show_on_sidebar: true,
},
];
const mockTeams = [
{
id: 1,
name: '⚙️ sales team',
description: 'This is our internal sales team',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 2,
name: '🤷‍♂️ fayaz',
description: 'Test',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
{
id: 3,
name: '🇮🇳 apac sales',
description: 'Sales team for France Territory',
allow_auto_assign: true,
account_id: 1,
is_member: true,
},
];
const mockAgents = [
{
id: 1,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'john@doe.com',
available_name: 'John Doe',
name: 'John Doe',
role: 'agent',
thumbnail: 'https://example.com/image.png',
},
{
id: 9,
account_id: 1,
availability_status: 'offline',
auto_offline: true,
confirmed: true,
email: 'clark@kent.com',
available_name: 'Clark Kent',
name: 'Clark Kent',
role: 'agent',
thumbnail: '',
},
];
beforeEach(() => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: mockLabels },
'teams/getTeams': { value: mockTeams },
'agents/getAgents': { value: mockAgents },
});
});
it('initializes computed properties correctly', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
expect(getMacroDropdownValues('assign_team')).toHaveLength(
mockTeams.length
);
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
mockAgents.length + 1
); // +1 for "Self"
});
it('returns teams for assign_team and send_email_to_team types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
});
it('returns agents with "Self" option for assign_agent type', () => {
const { getMacroDropdownValues } = useMacros();
const result = getMacroDropdownValues('assign_agent');
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
expect(result.slice(1)).toEqual(mockAgents);
});
it('returns formatted labels for add_label and remove_label types', () => {
const { getMacroDropdownValues } = useMacros();
const expectedLabels = mockLabels.map(i => ({
id: i.title,
name: i.title,
}));
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
});
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
const { getMacroDropdownValues } = useMacros();
const expectedPriority = PRIORITY_CONDITION_VALUES.map(item => ({
id: item.id,
name: `MACROS.PRIORITY_TYPES.${item.i18nKey}`,
}));
expect(getMacroDropdownValues('change_priority')).toEqual(expectedPriority);
});
it('returns an empty array for unknown types', () => {
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
});
it('handles empty data correctly', () => {
useStoreGetters.mockReturnValue({
'labels/getLabels': { value: [] },
'teams/getTeams': { value: [] },
'agents/getAgents': { value: [] },
});
const { getMacroDropdownValues } = useMacros();
expect(getMacroDropdownValues('add_label')).toEqual([]);
expect(getMacroDropdownValues('assign_team')).toEqual([]);
expect(getMacroDropdownValues('assign_agent')).toEqual([
{ id: 'self', name: 'Self' },
]);
});
});

View File

@@ -0,0 +1,61 @@
import { ref } from 'vue';
import { useReportMetrics } from '../useReportMetrics';
import { useMapGetter } from 'dashboard/composables/store';
import { summary, botSummary } from './fixtures/reportFixtures';
vi.mock('dashboard/composables/store');
vi.mock('@chatwoot/utils', () => ({
formatTime: vi.fn(time => `formatted_${time}`),
}));
describe('useReportMetrics', () => {
beforeEach(() => {
vi.clearAllMocks();
useMapGetter.mockReturnValue(ref(summary));
});
it('calculates trend correctly', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('conversations_count')).toBe(124900);
expect(calculateTrend('incoming_messages_count')).toBe(0);
expect(calculateTrend('avg_first_response_time')).toBe(123);
});
it('returns 0 for trend when previous value is not available', () => {
const { calculateTrend } = useReportMetrics();
expect(calculateTrend('non_existent_key')).toBe(0);
});
it('identifies average metric types correctly', () => {
const { isAverageMetricType } = useReportMetrics();
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
expect(isAverageMetricType('reply_time')).toBe(true);
expect(isAverageMetricType('conversations_count')).toBe(false);
});
it('displays metrics correctly for account', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('conversations_count')).toBe('5,000');
expect(displayMetric('incoming_messages_count')).toBe('5');
});
it('displays the metric for bot', () => {
const customKey = 'getBotSummary';
useMapGetter.mockReturnValue(ref(botSummary));
const { displayMetric } = useReportMetrics(customKey);
expect(displayMetric('bot_resolutions_count')).toBe('10');
expect(displayMetric('bot_handoffs_count')).toBe('20');
});
it('handles non-existent metrics', () => {
const { displayMetric } = useReportMetrics();
expect(displayMetric('non_existent_key')).toBe('0');
});
});

View File

@@ -0,0 +1,31 @@
import { selectTranslation } from '../useTranslations';
describe('selectTranslation', () => {
it('returns null when translations is null', () => {
expect(selectTranslation(null, 'en', 'en')).toBeNull();
});
it('returns null when translations is empty', () => {
expect(selectTranslation({}, 'en', 'en')).toBeNull();
});
it('returns first translation when no locale matches', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, 'fr', 'de')).toBe('Hello');
});
it('returns translation matching agent locale', () => {
const translations = { en: 'Hello', es: 'Hola', zh_CN: '你好' };
expect(selectTranslation(translations, 'es', 'en')).toBe('Hola');
});
it('falls back to account locale when agent locale not found', () => {
const translations = { en: 'Hello', zh_CN: '你好' };
expect(selectTranslation(translations, 'fr', 'zh_CN')).toBe('你好');
});
it('returns first translation when both locales are undefined', () => {
const translations = { en: 'Hello', es: 'Hola' };
expect(selectTranslation(translations, undefined, undefined)).toBe('Hello');
});
});

View File

@@ -0,0 +1,163 @@
import { ref } from 'vue';
import {
useUISettings,
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
} from 'dashboard/composables/useUISettings';
// Mocking the store composables
const mockDispatch = vi.fn();
const getUISettingsMock = ref({
is_ct_labels_open: true,
conversation_sidebar_items_order: DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
vi.mock('dashboard/composables/store', () => ({
useStoreGetters: () => ({
getUISettings: getUISettingsMock,
}),
useStore: () => ({
dispatch: mockDispatch,
}),
}));
describe('useUISettings', () => {
beforeEach(() => {
mockDispatch.mockClear();
});
it('returns uiSettings', () => {
const { uiSettings } = useUISettings();
expect(uiSettings.value).toEqual({
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
});
});
it('updates UI settings correctly', () => {
const { updateUISettings } = useUISettings();
updateUISettings({ enter_to_send_enabled: true });
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
enter_to_send_enabled: true,
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('toggles sidebar UI state correctly', () => {
const { toggleSidebarUIState } = useUISettings();
toggleSidebarUIState('is_ct_labels_open');
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: false,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('returns correct conversation sidebar items order', () => {
const { conversationSidebarItemsOrder } = useUISettings();
expect(conversationSidebarItemsOrder.value).toEqual(
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER
);
});
it('returns correct contact sidebar items order', () => {
const { contactSidebarItemsOrder } = useUISettings();
expect(contactSidebarItemsOrder.value).toEqual(
DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER
);
});
it('returns correct value for isContactSidebarItemOpen', () => {
const { isContactSidebarItemOpen } = useUISettings();
expect(isContactSidebarItemOpen('is_ct_labels_open')).toBe(true);
expect(isContactSidebarItemOpen('non_existent_key')).toBe(false);
});
it('sets signature flag for inbox correctly', () => {
const { setSignatureFlagForInbox } = useUISettings();
setSignatureFlagForInbox('email', true);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
email_signature_enabled: true,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: true,
},
});
});
it('fetches signature flag from UI settings correctly', () => {
const { fetchSignatureFlagFromUISettings } = useUISettings();
expect(fetchSignatureFlagFromUISettings('email')).toBe(undefined);
});
it('sets quoted reply flag for inbox correctly', () => {
const { setQuotedReplyFlagForInbox } = useUISettings();
setQuotedReplyFlagForInbox('Channel::Email', false);
expect(mockDispatch).toHaveBeenCalledWith('updateUISettings', {
uiSettings: {
is_ct_labels_open: true,
conversation_sidebar_items_order:
DEFAULT_CONVERSATION_SIDEBAR_ITEMS_ORDER,
contact_sidebar_items_order: DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER,
editor_message_key: 'enter',
channel_email_quoted_reply_enabled: false,
},
});
});
it('fetches quoted reply flag from UI settings correctly', () => {
const { fetchQuotedReplyFlagFromUISettings } = useUISettings();
expect(fetchQuotedReplyFlagFromUISettings('Channel::Email')).toBe(true);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is configured', () => {
getUISettingsMock.value.enter_to_send_enabled = false;
const { isEditorHotKeyEnabled } = useUISettings();
expect(isEditorHotKeyEnabled('enter')).toBe(true);
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(false);
});
it('returns correct value for isEditorHotKeyEnabled when editor_message_key is not configured', () => {
getUISettingsMock.value.editor_message_key = undefined;
const { isEditorHotKeyEnabled } = useUISettings();
expect(isEditorHotKeyEnabled('enter')).toBe(false);
expect(isEditorHotKeyEnabled('cmd_enter')).toBe(true);
});
it('handles non-existent keys', () => {
const {
isContactSidebarItemOpen,
fetchSignatureFlagFromUISettings,
isEditorHotKeyEnabled,
} = useUISettings();
expect(isContactSidebarItemOpen('non_existent_key')).toBe(false);
expect(fetchSignatureFlagFromUISettings('non_existent_key')).toBe(
undefined
);
expect(isEditorHotKeyEnabled('non_existent_key')).toBe(false);
});
});