Merge branch 'develop' into fix/CW-3385
This commit is contained in:
@@ -38,9 +38,10 @@ export class AnalyticsHelper {
|
||||
* @param {Object} user - User object
|
||||
*/
|
||||
identify(user) {
|
||||
if (!this.analytics) {
|
||||
if (!this.analytics || !user) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.user = user;
|
||||
this.analytics.identify(this.user.email, {
|
||||
userId: this.user.id,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import helperObject, { AnalyticsHelper } from '../';
|
||||
|
||||
jest.mock('@june-so/analytics-next', () => ({
|
||||
vi.mock('@june-so/analytics-next', () => ({
|
||||
AnalyticsBrowser: {
|
||||
load: () => [
|
||||
{
|
||||
identify: jest.fn(),
|
||||
track: jest.fn(),
|
||||
page: jest.fn(),
|
||||
group: jest.fn(),
|
||||
identify: vi.fn(),
|
||||
track: vi.fn(),
|
||||
page: vi.fn(),
|
||||
group: vi.fn(),
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -40,7 +40,7 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('identify', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { identify: jest.fn(), group: jest.fn() };
|
||||
analyticsHelper.analytics = { identify: vi.fn(), group: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call identify on analytics browser with correct arguments', () => {
|
||||
@@ -87,7 +87,7 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('track', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { track: jest.fn() };
|
||||
analyticsHelper.analytics = { track: vi.fn() };
|
||||
analyticsHelper.user = { id: '123' };
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('AnalyticsHelper', () => {
|
||||
|
||||
describe('page', () => {
|
||||
beforeEach(() => {
|
||||
analyticsHelper.analytics = { page: jest.fn() };
|
||||
analyticsHelper.analytics = { page: vi.fn() };
|
||||
});
|
||||
|
||||
it('should call the analytics.page method with the correct arguments', () => {
|
||||
|
||||
@@ -2,34 +2,35 @@ import Vue from 'vue';
|
||||
import plugin from '../plugin';
|
||||
import analyticsHelper from '../index';
|
||||
|
||||
vi.spyOn(analyticsHelper, 'init');
|
||||
vi.spyOn(analyticsHelper, 'track');
|
||||
|
||||
describe('Vue Analytics Plugin', () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(analyticsHelper, 'init');
|
||||
jest.spyOn(analyticsHelper, 'track');
|
||||
Vue.use(plugin);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetModules();
|
||||
jest.clearAllMocks();
|
||||
it('should call the init method on analyticsHelper once during plugin installation', () => {
|
||||
expect(analyticsHelper.init).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call the init method on the analyticsHelper', () => {
|
||||
expect(analyticsHelper.init).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should add the analyticsHelper to the Vue prototype', () => {
|
||||
it('should add the analyticsHelper to the Vue prototype as $analytics', () => {
|
||||
expect(Vue.prototype.$analytics).toBe(analyticsHelper);
|
||||
});
|
||||
|
||||
it('should add the track method to the Vue prototype', () => {
|
||||
it('should add a track method to the Vue prototype as $track', () => {
|
||||
expect(typeof Vue.prototype.$track).toBe('function');
|
||||
Vue.prototype.$track('eventName');
|
||||
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
|
||||
expect(analyticsHelper.track)
|
||||
.toHaveBeenCalledTimes(1)
|
||||
.toHaveBeenCalledWith('eventName');
|
||||
});
|
||||
|
||||
it('should call the track method on the analyticsHelper when $track is called', () => {
|
||||
Vue.prototype.$track('eventName');
|
||||
expect(analyticsHelper.track).toHaveBeenCalledWith('eventName');
|
||||
it('should call the track method on analyticsHelper with the correct event name when $track is called', () => {
|
||||
const eventName = 'testEvent';
|
||||
Vue.prototype.$track(eventName);
|
||||
expect(analyticsHelper.track)
|
||||
.toHaveBeenCalledTimes(1)
|
||||
.toHaveBeenCalledWith(eventName);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -68,6 +68,10 @@ class DashboardAudioNotificationHelper {
|
||||
};
|
||||
|
||||
executeRecurringNotification = () => {
|
||||
if (!window.WOOT || !window.WOOT.$store) {
|
||||
this.clearSetTimeout();
|
||||
return;
|
||||
}
|
||||
const mineConversation = window.WOOT.$store.getters.getMineChats({
|
||||
assigneeType: 'me',
|
||||
status: 'open',
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
const SCRIPT_TYPE = 'text/javascript';
|
||||
const DATA_LOADED_ATTR = 'data-loaded';
|
||||
const SCRIPT_PROPERTIES = [
|
||||
'defer',
|
||||
'crossOrigin',
|
||||
'noModule',
|
||||
'referrerPolicy',
|
||||
'id',
|
||||
];
|
||||
|
||||
/**
|
||||
* Custom error class for script loading failures.
|
||||
* @extends Error
|
||||
*/
|
||||
class ScriptLoaderError extends Error {
|
||||
/**
|
||||
* Creates a new ScriptLoaderError.
|
||||
* @param {string} src - The source URL of the script that failed to load.
|
||||
* @param {string} message - The error message.
|
||||
*/
|
||||
constructor(src, message = 'Failed to load script') {
|
||||
super(message);
|
||||
this.name = 'ScriptLoaderError';
|
||||
this.src = src;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets detailed error information.
|
||||
* @returns {string} A string containing the error details.
|
||||
*/
|
||||
getErrorDetails() {
|
||||
return `Failed to load script from source: ${this.src}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new script element with the specified attributes.
|
||||
* @param {string} src - The source URL of the script.
|
||||
* @param {Object} options - Options for configuring the script element.
|
||||
* @param {string} [options.type='text/javascript'] - The type of the script.
|
||||
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
|
||||
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
|
||||
* @param {string} [options.crossOrigin] - The CORS setting for the script.
|
||||
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
|
||||
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
|
||||
* @param {string} [options.id] - The id attribute for the script element.
|
||||
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
|
||||
* @returns {HTMLScriptElement} The created script element.
|
||||
*/
|
||||
const createScriptElement = (src, options) => {
|
||||
const el = document.createElement('script');
|
||||
el.type = options.type || SCRIPT_TYPE;
|
||||
el.async = options.async !== false;
|
||||
el.src = src;
|
||||
|
||||
SCRIPT_PROPERTIES.forEach(property => {
|
||||
if (property in options) {
|
||||
el[property] = options[property];
|
||||
}
|
||||
});
|
||||
|
||||
Object.entries(options.attrs || {}).forEach(([name, value]) =>
|
||||
el.setAttribute(name, value)
|
||||
);
|
||||
|
||||
return el;
|
||||
};
|
||||
|
||||
/**
|
||||
* Finds an existing script element with the specified source URL.
|
||||
* @param {string} src - The source URL to search for.
|
||||
* @returns {HTMLScriptElement|null} The found script element, or null if not found.
|
||||
*/
|
||||
const findExistingScript = src => {
|
||||
return document.querySelector(`script[src="${src}"]`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Loads a script asynchronously and returns a promise.
|
||||
* @param {string} src - The source URL of the script to load.
|
||||
* @param {Object} options - Options for configuring the script element.
|
||||
* @param {string} [options.type='text/javascript'] - The type of the script.
|
||||
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
|
||||
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
|
||||
* @param {string} [options.crossOrigin] - The CORS setting for the script.
|
||||
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
|
||||
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
|
||||
* @param {string} [options.id] - The id attribute for the script element.
|
||||
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
|
||||
* @returns {Promise<HTMLScriptElement|boolean>} A promise that resolves with the loaded script element,
|
||||
* or false if the script couldn't be loaded.
|
||||
* @throws {ScriptLoaderError} If the script fails to load.
|
||||
*/
|
||||
export async function loadScript(src, options) {
|
||||
if (typeof window === 'undefined' || !window.document) {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof src !== 'string' || src.trim() === '') {
|
||||
reject(new Error('Invalid source URL provided'));
|
||||
return;
|
||||
}
|
||||
|
||||
let el = findExistingScript(src);
|
||||
|
||||
if (!el) {
|
||||
el = createScriptElement(src, options);
|
||||
document.head.appendChild(el);
|
||||
} else if (el.hasAttribute(DATA_LOADED_ATTR)) {
|
||||
resolve(el);
|
||||
return;
|
||||
}
|
||||
|
||||
const handleError = () => reject(new ScriptLoaderError(src));
|
||||
|
||||
el.addEventListener('error', handleError);
|
||||
el.addEventListener('abort', handleError);
|
||||
el.addEventListener('load', () => {
|
||||
el.setAttribute(DATA_LOADED_ATTR, 'true');
|
||||
resolve(el);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
|
||||
const MAX_DISCONNECT_SECONDS = 10800;
|
||||
|
||||
// The disconnect delay threshold is added to account for delays in identifying
|
||||
// disconnections (for example, the websocket disconnection takes up to 3 seconds)
|
||||
// while fetching the latest updated conversations or messages.
|
||||
const DISCONNECT_DELAY_THRESHOLD = 15;
|
||||
|
||||
class ReconnectService {
|
||||
constructor(store, router) {
|
||||
this.store = store;
|
||||
@@ -47,7 +52,8 @@ class ReconnectService {
|
||||
fetchConversations = async () => {
|
||||
await this.store.dispatch('updateChatListFilters', {
|
||||
page: null,
|
||||
updatedWithin: this.getSecondsSinceDisconnect(),
|
||||
updatedWithin:
|
||||
this.getSecondsSinceDisconnect() + DISCONNECT_DELAY_THRESHOLD,
|
||||
});
|
||||
await this.store.dispatch('fetchAllConversations');
|
||||
// Reset the updatedWithin in the store chat list filter after fetching conversations when the user is reconnected
|
||||
|
||||
@@ -30,6 +30,7 @@ const translationKeys = {
|
||||
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
|
||||
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
|
||||
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
|
||||
'accountuser:update:deleted': `AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED`,
|
||||
'inboxmember:create': `AUDIT_LOGS.INBOX_MEMBER.ADD`,
|
||||
'inboxmember:destroy': `AUDIT_LOGS.INBOX_MEMBER.REMOVE`,
|
||||
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
|
||||
@@ -89,9 +90,9 @@ function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
|
||||
}
|
||||
|
||||
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
|
||||
if (auditLogItem.user_id !== auditLogItem.auditable.user_id) {
|
||||
if (auditLogItem.user_id !== auditLogItem.auditable?.user_id) {
|
||||
translationPayload.user = getAgentName(
|
||||
auditLogItem.auditable.user_id,
|
||||
auditLogItem.auditable?.user_id,
|
||||
agentList
|
||||
);
|
||||
}
|
||||
@@ -187,16 +188,25 @@ export function generateTranslationPayload(auditLogItem, agentList) {
|
||||
return translationPayload;
|
||||
}
|
||||
|
||||
function getAccountUserUpdateSuffix(auditLogItem) {
|
||||
if (auditLogItem.auditable === null) {
|
||||
// If the user is deleted, we don't need to check if the user is the same as the auditLogItem.user_id
|
||||
// Else we can use the deleted translation key
|
||||
// It doesn't need the agent name
|
||||
return ':deleted';
|
||||
}
|
||||
return auditLogItem.user_id === auditLogItem.auditable.user_id
|
||||
? ':self'
|
||||
: ':other';
|
||||
}
|
||||
|
||||
export const generateLogActionKey = auditLogItem => {
|
||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||
const action = auditLogItem.action.toLowerCase();
|
||||
let logActionKey = `${auditableType}:${action}`;
|
||||
|
||||
if (auditableType === 'accountuser' && action === 'update') {
|
||||
logActionKey +=
|
||||
auditLogItem.user_id === auditLogItem.auditable.user_id
|
||||
? ':self'
|
||||
: ':other';
|
||||
logActionKey += getAccountUserUpdateSuffix(auditLogItem);
|
||||
}
|
||||
|
||||
return translationKeys[logActionKey] || '';
|
||||
|
||||
@@ -121,7 +121,7 @@ export const generateConditionOptions = (options, key = 'id') => {
|
||||
};
|
||||
|
||||
// Add the "None" option to the agent list
|
||||
export const agentList = agents => [
|
||||
export const addNoneToList = agents => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
@@ -137,8 +137,8 @@ export const getActionOptions = ({
|
||||
type,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: agentList(agents),
|
||||
assign_team: teams,
|
||||
assign_agent: addNoneToList(agents),
|
||||
assign_team: addNoneToList(teams),
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
|
||||
@@ -1,4 +1,21 @@
|
||||
const FEATURE_HELP_URLS = {
|
||||
agent_bots: 'https://chwt.app/hc/agent-bots',
|
||||
audit_logs: 'https://chwt.app/hc/audit-logs',
|
||||
campaigns: 'https://chwt.app/hc/campaigns',
|
||||
canned_responses: 'https://chwt.app/hc/canned',
|
||||
channel_email: 'https://chwt.app/hc/email',
|
||||
channel_facebook: 'https://chwt.app/hc/fb',
|
||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
const { helpUrls } = window.chatwootConfig;
|
||||
return helpUrls[featureName];
|
||||
return FEATURE_HELP_URLS[featureName];
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export const hasPermissions = (
|
||||
requiredPermissions = [],
|
||||
availablePermissions = []
|
||||
) => {
|
||||
return requiredPermissions.some(permission =>
|
||||
availablePermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
const isPermissionsPresentInRoute = route =>
|
||||
route.meta && route.meta.permissions;
|
||||
|
||||
export const buildPermissionsFromRouter = (routes = []) =>
|
||||
routes.reduce((acc, route) => {
|
||||
if (route.name) {
|
||||
if (!isPermissionsPresentInRoute(route)) {
|
||||
// eslint-disable-next-line
|
||||
console.error(route);
|
||||
throw new Error(
|
||||
"The route doesn't have the required permissions defined"
|
||||
);
|
||||
}
|
||||
acc[route.name] = route.meta.permissions;
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
acc = {
|
||||
...acc,
|
||||
...buildPermissionsFromRouter(route.children),
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
import NotificationSubscriptions from '../api/notificationSubscription';
|
||||
import auth from '../api/auth';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
@@ -69,19 +69,13 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
onSuccess();
|
||||
})
|
||||
.catch(() => {
|
||||
emitter.emit(
|
||||
'newToastMessage',
|
||||
'This browser does not support desktop notification'
|
||||
);
|
||||
useAlert('This browser does not support desktop notification');
|
||||
});
|
||||
};
|
||||
|
||||
export const requestPushPermissions = ({ onSuccess }) => {
|
||||
if (!('Notification' in window)) {
|
||||
emitter.emit(
|
||||
'newToastMessage',
|
||||
'This browser does not support desktop notification'
|
||||
);
|
||||
useAlert('This browser does not support desktop notification');
|
||||
} else if (Notification.permission === 'granted') {
|
||||
registerSubscription(onSuccess);
|
||||
} else if (Notification.permission !== 'denied') {
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { hasPermissions } from './permissionsHelper';
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
|
||||
return accounts.find(account => account.id === accountId);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getUserRole = ({ accounts } = {}, accountId) => {
|
||||
const currentAccount = getCurrentAccount({ accounts }, accountId) || {};
|
||||
return currentAccount.role || null;
|
||||
export const routeIsAccessibleFor = (route, userPermissions = []) => {
|
||||
const { meta: { permissions: routePermissions = [] } = {} } = route;
|
||||
return hasPermissions(routePermissions, userPermissions);
|
||||
};
|
||||
|
||||
export const routeIsAccessibleFor = (route, role, roleWiseRoutes) => {
|
||||
return roleWiseRoutes[role].includes(route);
|
||||
};
|
||||
|
||||
const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
|
||||
const validateActiveAccountRoutes = (to, user) => {
|
||||
// If the current account is active, then check for the route permissions
|
||||
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
|
||||
|
||||
@@ -22,15 +19,13 @@ const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
|
||||
return accountDashboardURL;
|
||||
}
|
||||
|
||||
const userRole = getUserRole(user, Number(to.params.accountId));
|
||||
const isAccessible = routeIsAccessibleFor(to.name, userRole, roleWiseRoutes);
|
||||
const isAccessible = routeIsAccessibleFor(to, user.permissions);
|
||||
// If the route is not accessible for the user, return to dashboard screen
|
||||
return isAccessible ? null : accountDashboardURL;
|
||||
};
|
||||
|
||||
export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
export const validateLoggedInRoutes = (to, user) => {
|
||||
const currentAccount = getCurrentAccount(user, Number(to.params.accountId));
|
||||
|
||||
// If current account is missing, either user does not have
|
||||
// access to the account or the account is deleted, return to login screen
|
||||
if (!currentAccount) {
|
||||
@@ -40,7 +35,7 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
const isCurrentAccountActive = currentAccount.status === 'active';
|
||||
|
||||
if (isCurrentAccountActive) {
|
||||
return validateActiveAccountRoutes(to, user, roleWiseRoutes);
|
||||
return validateActiveAccountRoutes(to, user);
|
||||
}
|
||||
|
||||
// If the current account is not active, then redirect the user to the suspended screen
|
||||
|
||||
@@ -4,7 +4,7 @@ describe('DataManager', () => {
|
||||
const accountId = 'test-account';
|
||||
let dataManager;
|
||||
|
||||
beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
dataManager = new DataManager(accountId);
|
||||
await dataManager.initDb();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { loadScript } from '../DOMHelpers';
|
||||
import { JSDOM } from 'jsdom';
|
||||
|
||||
describe('loadScript', () => {
|
||||
let dom;
|
||||
let window;
|
||||
let document;
|
||||
|
||||
beforeEach(() => {
|
||||
dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
|
||||
url: 'http://localhost',
|
||||
});
|
||||
window = dom.window;
|
||||
document = window.document;
|
||||
global.document = document;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
delete global.document;
|
||||
});
|
||||
|
||||
it('should load a script successfully', async () => {
|
||||
const src = 'https://example.com/script.js';
|
||||
const loadPromise = loadScript(src, {});
|
||||
|
||||
// Simulate successful script load
|
||||
setTimeout(() => {
|
||||
const script = document.querySelector(`script[src="${src}"]`);
|
||||
if (script) {
|
||||
script.dispatchEvent(new window.Event('load'));
|
||||
}
|
||||
}, 0);
|
||||
|
||||
const script = await loadPromise;
|
||||
|
||||
expect(script).toBeTruthy();
|
||||
expect(script.getAttribute('src')).toBe(src);
|
||||
expect(script.getAttribute('data-loaded')).toBe('true');
|
||||
});
|
||||
|
||||
it('should not load a script if document is not available', async () => {
|
||||
delete global.document;
|
||||
const result = await loadScript('https://example.com/script.js', {});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should use an existing script if already present', async () => {
|
||||
const src = 'https://example.com/existing-script.js';
|
||||
const existingScript = document.createElement('script');
|
||||
existingScript.src = src;
|
||||
existingScript.setAttribute('data-loaded', 'true');
|
||||
document.head.appendChild(existingScript);
|
||||
|
||||
const script = await loadScript(src, {});
|
||||
|
||||
expect(script).toBe(existingScript);
|
||||
});
|
||||
|
||||
it('should set custom attributes on the script element', async () => {
|
||||
const src = 'https://example.com/custom-script.js';
|
||||
const options = {
|
||||
type: 'module',
|
||||
async: false,
|
||||
defer: true,
|
||||
crossOrigin: 'anonymous',
|
||||
noModule: true,
|
||||
referrerPolicy: 'origin',
|
||||
id: 'custom-script',
|
||||
attrs: { 'data-custom': 'value' },
|
||||
};
|
||||
|
||||
const loadPromise = loadScript(src, options);
|
||||
|
||||
// Simulate successful script load
|
||||
setTimeout(() => {
|
||||
const script = document.querySelector(`script[src="${src}"]`);
|
||||
if (script) {
|
||||
script.dispatchEvent(new window.Event('load'));
|
||||
}
|
||||
}, 0);
|
||||
|
||||
const script = await loadPromise;
|
||||
|
||||
expect(script.type).toBe('module');
|
||||
expect(script.async).toBe(false);
|
||||
expect(script.defer).toBe(true);
|
||||
expect(script.crossOrigin).toBe('anonymous');
|
||||
expect(script.noModule).toBe(true);
|
||||
expect(script.referrerPolicy).toBe('origin');
|
||||
expect(script.id).toBe('custom-script');
|
||||
expect(script.getAttribute('data-custom')).toBe('value');
|
||||
});
|
||||
});
|
||||
@@ -8,26 +8,26 @@ import {
|
||||
} from 'dashboard/helper/routeHelpers';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
|
||||
jest.mock('shared/helpers/mitt', () => ({
|
||||
vi.mock('shared/helpers/mitt', () => ({
|
||||
emitter: {
|
||||
on: jest.fn(),
|
||||
off: jest.fn(),
|
||||
emit: jest.fn(),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('date-fns', () => ({
|
||||
differenceInSeconds: jest.fn(),
|
||||
vi.mock('date-fns', () => ({
|
||||
differenceInSeconds: vi.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('dashboard/helper/routeHelpers', () => ({
|
||||
isAConversationRoute: jest.fn(),
|
||||
isAInboxViewRoute: jest.fn(),
|
||||
isNotificationRoute: jest.fn(),
|
||||
vi.mock('dashboard/helper/routeHelpers', () => ({
|
||||
isAConversationRoute: vi.fn(),
|
||||
isAInboxViewRoute: vi.fn(),
|
||||
isNotificationRoute: vi.fn(),
|
||||
}));
|
||||
|
||||
const storeMock = {
|
||||
dispatch: jest.fn(),
|
||||
dispatch: vi.fn(),
|
||||
getters: {
|
||||
getAppliedConversationFiltersQuery: [],
|
||||
'customViews/getActiveConversationFolder': { query: {} },
|
||||
@@ -46,17 +46,17 @@ describe('ReconnectService', () => {
|
||||
let reconnectService;
|
||||
|
||||
beforeEach(() => {
|
||||
window.addEventListener = jest.fn();
|
||||
window.removeEventListener = jest.fn();
|
||||
window.addEventListener = vi.fn();
|
||||
window.removeEventListener = vi.fn();
|
||||
Object.defineProperty(window, 'location', {
|
||||
configurable: true,
|
||||
value: { reload: jest.fn() },
|
||||
value: { reload: vi.fn() },
|
||||
});
|
||||
reconnectService = new ReconnectService(storeMock, routerMock);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
@@ -102,7 +102,7 @@ describe('ReconnectService', () => {
|
||||
expect(reconnectService.getSecondsSinceDisconnect()).toBe(0);
|
||||
});
|
||||
|
||||
it('should return the number of seconds since disconnect', () => {
|
||||
it('should return the number of seconds + threshold since disconnect', () => {
|
||||
reconnectService.disconnectTime = new Date();
|
||||
differenceInSeconds.mockReturnValue(100);
|
||||
expect(reconnectService.getSecondsSinceDisconnect()).toBe(100);
|
||||
@@ -111,7 +111,7 @@ describe('ReconnectService', () => {
|
||||
|
||||
describe('handleOnlineEvent', () => {
|
||||
it('should reload the page if disconnected for more than 3 hours', () => {
|
||||
reconnectService.getSecondsSinceDisconnect = jest
|
||||
reconnectService.getSecondsSinceDisconnect = vi
|
||||
.fn()
|
||||
.mockReturnValue(10801);
|
||||
reconnectService.handleOnlineEvent();
|
||||
@@ -119,7 +119,7 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
it('should not reload the page if disconnected for less than 3 hours', () => {
|
||||
reconnectService.getSecondsSinceDisconnect = jest
|
||||
reconnectService.getSecondsSinceDisconnect = vi
|
||||
.fn()
|
||||
.mockReturnValue(10799);
|
||||
reconnectService.handleOnlineEvent();
|
||||
@@ -128,22 +128,27 @@ describe('ReconnectService', () => {
|
||||
});
|
||||
|
||||
describe('fetchConversations', () => {
|
||||
it('should dispatch updateChatListFilters and fetchAllConversations', async () => {
|
||||
reconnectService.getSecondsSinceDisconnect = jest
|
||||
.fn()
|
||||
.mockReturnValue(100);
|
||||
it('should update the filters with disconnected time and the threshold', async () => {
|
||||
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
|
||||
await reconnectService.fetchConversations();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
|
||||
page: null,
|
||||
updatedWithin: 100,
|
||||
updatedWithin: 115,
|
||||
});
|
||||
});
|
||||
|
||||
it('should dispatch updateChatListFilters and fetchAllConversations', async () => {
|
||||
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
|
||||
await reconnectService.fetchConversations();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
|
||||
page: null,
|
||||
updatedWithin: 115,
|
||||
});
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('fetchAllConversations');
|
||||
});
|
||||
|
||||
it('should dispatch updateChatListFilters and reset updatedWithin', async () => {
|
||||
reconnectService.getSecondsSinceDisconnect = jest
|
||||
.fn()
|
||||
.mockReturnValue(100);
|
||||
reconnectService.getSecondsSinceDisconnect = vi.fn().mockReturnValue(100);
|
||||
await reconnectService.fetchConversations();
|
||||
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
|
||||
updatedWithin: null,
|
||||
@@ -173,7 +178,7 @@ describe('ReconnectService', () => {
|
||||
},
|
||||
],
|
||||
};
|
||||
const spy = jest.spyOn(
|
||||
const spy = vi.spyOn(
|
||||
reconnectService,
|
||||
'fetchFilteredOrSavedConversations'
|
||||
);
|
||||
@@ -191,7 +196,7 @@ describe('ReconnectService', () => {
|
||||
query: null,
|
||||
};
|
||||
|
||||
const spy = jest.spyOn(reconnectService, 'fetchConversations');
|
||||
const spy = vi.spyOn(reconnectService, 'fetchConversations');
|
||||
|
||||
await reconnectService.fetchConversationsOnReconnect();
|
||||
|
||||
@@ -204,7 +209,7 @@ describe('ReconnectService', () => {
|
||||
query: { test: 'activeFolderQuery' },
|
||||
};
|
||||
|
||||
const spy = jest.spyOn(
|
||||
const spy = vi.spyOn(
|
||||
reconnectService,
|
||||
'fetchFilteredOrSavedConversations'
|
||||
);
|
||||
@@ -270,11 +275,11 @@ describe('ReconnectService', () => {
|
||||
describe('handleRouteSpecificFetch', () => {
|
||||
it('should fetch conversations and messages if current route is a conversation route', async () => {
|
||||
isAConversationRoute.mockReturnValue(true);
|
||||
const spyConversations = jest.spyOn(
|
||||
const spyConversations = vi.spyOn(
|
||||
reconnectService,
|
||||
'fetchConversationsOnReconnect'
|
||||
);
|
||||
const spyMessages = jest.spyOn(
|
||||
const spyMessages = vi.spyOn(
|
||||
reconnectService,
|
||||
'fetchConversationMessagesOnReconnect'
|
||||
);
|
||||
@@ -285,14 +290,14 @@ describe('ReconnectService', () => {
|
||||
|
||||
it('should fetch notifications if current route is an inbox view route', async () => {
|
||||
isAInboxViewRoute.mockReturnValue(true);
|
||||
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
|
||||
const spy = vi.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
|
||||
await reconnectService.handleRouteSpecificFetch();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should fetch notifications if current route is a notification route', async () => {
|
||||
isNotificationRoute.mockReturnValue(true);
|
||||
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
|
||||
const spy = vi.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
|
||||
await reconnectService.handleRouteSpecificFetch();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
@@ -320,7 +325,7 @@ describe('ReconnectService', () => {
|
||||
|
||||
describe('onDisconnect', () => {
|
||||
it('should set disconnectTime and call setConversationLastMessageId', () => {
|
||||
reconnectService.setConversationLastMessageId = jest.fn();
|
||||
reconnectService.setConversationLastMessageId = vi.fn();
|
||||
reconnectService.onDisconnect();
|
||||
expect(reconnectService.disconnectTime).toBeInstanceOf(Date);
|
||||
expect(reconnectService.setConversationLastMessageId).toHaveBeenCalled();
|
||||
@@ -329,8 +334,8 @@ describe('ReconnectService', () => {
|
||||
|
||||
describe('onReconnect', () => {
|
||||
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
|
||||
reconnectService.handleRouteSpecificFetch = jest.fn();
|
||||
reconnectService.revalidateCaches = jest.fn();
|
||||
reconnectService.handleRouteSpecificFetch = vi.fn();
|
||||
reconnectService.revalidateCaches = vi.fn();
|
||||
await reconnectService.onReconnect();
|
||||
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
|
||||
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
|
||||
|
||||
@@ -178,5 +178,17 @@ describe('Helper functions', () => {
|
||||
const logActionKey = generateLogActionKey(auditLogItem);
|
||||
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER');
|
||||
});
|
||||
|
||||
it('should generate correct action key when updating a deleted user', () => {
|
||||
const auditLogItem = {
|
||||
auditable_type: 'AccountUser',
|
||||
action: 'update',
|
||||
user_id: 1,
|
||||
auditable: null,
|
||||
};
|
||||
|
||||
const logActionKey = generateLogActionKey(auditLogItem);
|
||||
expect(logActionKey).toEqual('AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -36,16 +36,14 @@ describe('#createPendingMessage', () => {
|
||||
message: 'hi',
|
||||
};
|
||||
it('returns the pending message with expected new keys', () => {
|
||||
expect(createPendingMessage(message)).toHaveProperty(
|
||||
'content',
|
||||
'id',
|
||||
'status',
|
||||
'echo_id',
|
||||
'status',
|
||||
'created_at',
|
||||
'message_type',
|
||||
'conversation_id'
|
||||
);
|
||||
expect(createPendingMessage(message)).toMatchObject({
|
||||
content: expect.anything(),
|
||||
id: expect.anything(),
|
||||
status: expect.anything(),
|
||||
echo_id: expect.anything(),
|
||||
created_at: expect.anything(),
|
||||
message_type: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the pending message with status progress', () => {
|
||||
@@ -61,23 +59,20 @@ describe('#createPendingMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the pending message with attachmnet key if file is passed', () => {
|
||||
it('returns the pending message with attachment key if file is passed', () => {
|
||||
const messageWithFile = {
|
||||
message: 'hi',
|
||||
file: {},
|
||||
};
|
||||
expect(createPendingMessage(messageWithFile)).toHaveProperty(
|
||||
'content',
|
||||
'id',
|
||||
'status',
|
||||
'echo_id',
|
||||
'status',
|
||||
'created_at',
|
||||
'message_type',
|
||||
'conversation_id',
|
||||
'attachments',
|
||||
'private'
|
||||
);
|
||||
expect(createPendingMessage(messageWithFile)).toMatchObject({
|
||||
content: expect.anything(),
|
||||
id: expect.anything(),
|
||||
status: expect.anything(),
|
||||
echo_id: expect.anything(),
|
||||
created_at: expect.anything(),
|
||||
message_type: expect.anything(),
|
||||
attachments: [{ id: expect.anything() }],
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the pending message to have one attachment', () => {
|
||||
|
||||
@@ -19,19 +19,19 @@ describe('resize directive', () => {
|
||||
beforeEach(() => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
value: vi.fn(),
|
||||
};
|
||||
observer = {
|
||||
observe: jest.fn(),
|
||||
unobserve: jest.fn(),
|
||||
disconnect: jest.fn(),
|
||||
observe: vi.fn(),
|
||||
unobserve: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
};
|
||||
window.ResizeObserver = ResizeObserverMock;
|
||||
jest.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
|
||||
vi.spyOn(window, 'ResizeObserver').mockImplementation(() => observer);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should create ResizeObserver on bind', () => {
|
||||
@@ -44,7 +44,7 @@ describe('resize directive', () => {
|
||||
it('should call callback on observer callback', () => {
|
||||
el = document.createElement('div');
|
||||
binding = {
|
||||
value: jest.fn(),
|
||||
value: vi.fn(),
|
||||
};
|
||||
|
||||
resize.bind(el, binding);
|
||||
|
||||
@@ -283,17 +283,17 @@ describe('findNodeToInsertImage', () => {
|
||||
mockEditorState = {
|
||||
selection: {
|
||||
$from: {
|
||||
node: jest.fn(() => ({})),
|
||||
node: vi.fn(() => ({})),
|
||||
},
|
||||
from: 0,
|
||||
},
|
||||
schema: {
|
||||
nodes: {
|
||||
image: {
|
||||
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
|
||||
create: vi.fn(attrs => ({ type: { name: 'image' }, attrs })),
|
||||
},
|
||||
paragraph: {
|
||||
create: jest.fn((_, node) => ({
|
||||
create: vi.fn((_, node) => ({
|
||||
type: { name: 'paragraph' },
|
||||
content: [node],
|
||||
})),
|
||||
@@ -381,11 +381,11 @@ describe('setURLWithQueryAndSize', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
selectedNode = {
|
||||
setAttribute: jest.fn(),
|
||||
setAttribute: vi.fn(),
|
||||
};
|
||||
|
||||
const tr = {
|
||||
setNodeMarkup: jest.fn().mockReturnValue({
|
||||
setNodeMarkup: vi.fn().mockReturnValue({
|
||||
docChanged: true,
|
||||
}),
|
||||
};
|
||||
@@ -397,7 +397,7 @@ describe('setURLWithQueryAndSize', () => {
|
||||
|
||||
editorView = {
|
||||
state,
|
||||
dispatch: jest.fn(),
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -420,7 +420,7 @@ describe('setURLWithQueryAndSize', () => {
|
||||
});
|
||||
|
||||
it('does not update the editor view if the document has not changed', () => {
|
||||
editorView.state.tr.setNodeMarkup = jest.fn().mockReturnValue({
|
||||
editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({
|
||||
docChanged: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
buildPermissionsFromRouter,
|
||||
hasPermissions,
|
||||
} from '../permissionsHelper';
|
||||
|
||||
describe('hasPermissions', () => {
|
||||
it('returns true if permission is present', () => {
|
||||
expect(
|
||||
hasPermissions(['contact_manage'], ['team_manage', 'contact_manage'])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if permission is not present', () => {
|
||||
expect(
|
||||
hasPermissions(['contact_manage'], ['team_manage', 'user_manage'])
|
||||
).toBe(false);
|
||||
expect(hasPermissions()).toBe(false);
|
||||
expect(hasPermissions([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPermissionsFromRouter', () => {
|
||||
it('returns a valid object when routes have permissions defined', () => {
|
||||
expect(
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: { permissions: ['agent_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'inbox',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'inbox_list',
|
||||
meta: { permissions: ['inbox_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'conversations',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: 'attachments',
|
||||
name: 'attachments_list',
|
||||
meta: { permissions: ['conversation_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
).toEqual({
|
||||
agent_list: ['agent_admin'],
|
||||
inbox_list: ['inbox_admin'],
|
||||
attachments_list: ['conversation_admin'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if a named routed does not have permissions defined', () => {
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getConversationDashboardRoute,
|
||||
getCurrentAccount,
|
||||
getUserRole,
|
||||
isAConversationRoute,
|
||||
routeIsAccessibleFor,
|
||||
validateLoggedInRoutes,
|
||||
@@ -15,24 +14,11 @@ describe('#getCurrentAccount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getUserRole', () => {
|
||||
it('should return the current role', () => {
|
||||
expect(
|
||||
getUserRole({ accounts: [{ id: 1, role: 'administrator' }] }, 1)
|
||||
).toEqual('administrator');
|
||||
expect(getUserRole({ accounts: [] }, 1)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#routeIsAccessibleFor', () => {
|
||||
it('should return the correct access', () => {
|
||||
const roleWiseRoutes = { agent: ['conversations'], admin: ['billing'] };
|
||||
expect(routeIsAccessibleFor('billing', 'agent', roleWiseRoutes)).toEqual(
|
||||
false
|
||||
);
|
||||
expect(routeIsAccessibleFor('billing', 'admin', roleWiseRoutes)).toEqual(
|
||||
true
|
||||
);
|
||||
let route = { meta: { permissions: ['administrator'] } };
|
||||
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false);
|
||||
expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,11 +26,7 @@ describe('#validateLoggedInRoutes', () => {
|
||||
describe('when account access is missing', () => {
|
||||
it('should return the login route', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ params: { accountId: 1 } },
|
||||
{ accounts: [] },
|
||||
{}
|
||||
)
|
||||
validateLoggedInRoutes({ params: { accountId: 1 } }, { accounts: [] })
|
||||
).toEqual(`app/login`);
|
||||
});
|
||||
});
|
||||
@@ -53,9 +35,12 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('return suspended route', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'suspended' }] },
|
||||
{ agent: ['conversations'] }
|
||||
{
|
||||
name: 'conversations',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['agent'] },
|
||||
},
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'suspended' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/suspended`);
|
||||
});
|
||||
@@ -65,9 +50,22 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('returns null (no action required)', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ agent: ['conversations'] }
|
||||
{
|
||||
name: 'conversations',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['agent'] },
|
||||
},
|
||||
{
|
||||
permissions: ['agent'],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'agent',
|
||||
permissions: ['agent'],
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
).toEqual(null);
|
||||
});
|
||||
@@ -76,9 +74,12 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('returns dashboard url', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ admin: ['conversations'], agent: [] }
|
||||
{
|
||||
name: 'billing',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
},
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/dashboard`);
|
||||
});
|
||||
@@ -88,8 +89,7 @@ describe('#validateLoggedInRoutes', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'account_suspended', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ agent: ['account_suspended'] }
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/dashboard`);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
|
||||
jest.mock('shared/helpers/localStorage');
|
||||
vi.mock('shared/helpers/localStorage');
|
||||
|
||||
describe('setColorTheme', () => {
|
||||
it('should set body class to dark if selectedColorScheme is dark', () => {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { uploadFile } from '../uploadHelper';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mocking axios using jest-mock-axios
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
vi.mock('axios');
|
||||
|
||||
describe('#Upload Helpers', () => {
|
||||
afterEach(() => {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
validateConversationOrContactFilters,
|
||||
validateAutomation,
|
||||
} from '../validations';
|
||||
|
||||
describe('validateConversationOrContactFilters', () => {
|
||||
it('should return no errors for valid filters', () => {
|
||||
const validFilters = [
|
||||
{ attribute_key: 'name', filter_operator: 'contains', values: 'John' },
|
||||
{ attribute_key: 'email', filter_operator: 'is_present' },
|
||||
];
|
||||
const errors = validateConversationOrContactFilters(validFilters);
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for invalid filters', () => {
|
||||
const invalidFilters = [
|
||||
{ attribute_key: '', filter_operator: 'contains', values: 'John' },
|
||||
{ attribute_key: 'email', filter_operator: '' },
|
||||
{ attribute_key: 'age', filter_operator: 'equals' },
|
||||
];
|
||||
const errors = validateConversationOrContactFilters(invalidFilters);
|
||||
expect(errors).toEqual({
|
||||
filter_0: 'ATTRIBUTE_KEY_REQUIRED',
|
||||
filter_1: 'FILTER_OPERATOR_REQUIRED',
|
||||
filter_2: 'VALUE_REQUIRED',
|
||||
});
|
||||
});
|
||||
|
||||
it('should validate days_before operator correctly', () => {
|
||||
const filters = [
|
||||
{ attribute_key: 'date', filter_operator: 'days_before', values: '0' },
|
||||
{ attribute_key: 'date', filter_operator: 'days_before', values: '999' },
|
||||
{ attribute_key: 'date', filter_operator: 'days_before', values: '500' },
|
||||
];
|
||||
const errors = validateConversationOrContactFilters(filters);
|
||||
expect(errors).toEqual({
|
||||
filter_0: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
|
||||
filter_1: 'VALUE_MUST_BE_BETWEEN_1_AND_998',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateAutomation', () => {
|
||||
it('should return no errors for a valid automation', () => {
|
||||
const validAutomation = {
|
||||
name: 'Test Automation',
|
||||
description: 'A test automation',
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'content',
|
||||
filter_operator: 'contains',
|
||||
values: 'hello',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{ action_name: 'send_message', action_params: ['Hello there!'] },
|
||||
],
|
||||
};
|
||||
const errors = validateAutomation(validAutomation);
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
|
||||
it('should return errors for missing basic fields', () => {
|
||||
const invalidAutomation = {
|
||||
name: '',
|
||||
description: '',
|
||||
event_name: '',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
};
|
||||
const errors = validateAutomation(invalidAutomation);
|
||||
expect(errors).toHaveProperty('name');
|
||||
expect(errors).toHaveProperty('description');
|
||||
expect(errors).toHaveProperty('event_name');
|
||||
});
|
||||
|
||||
it('should return errors for invalid conditions', () => {
|
||||
const automationWithInvalidConditions = {
|
||||
name: 'Test',
|
||||
description: 'Test',
|
||||
event_name: 'message_created',
|
||||
conditions: [{ attribute_key: '', filter_operator: '', values: '' }],
|
||||
actions: [{ action_name: 'send_message', action_params: ['Hello'] }],
|
||||
};
|
||||
const errors = validateAutomation(automationWithInvalidConditions);
|
||||
expect(errors).toHaveProperty('condition_0');
|
||||
});
|
||||
|
||||
it('should return errors for invalid actions', () => {
|
||||
const automationWithInvalidActions = {
|
||||
name: 'Test',
|
||||
description: 'Test',
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'content',
|
||||
filter_operator: 'contains',
|
||||
values: 'hello',
|
||||
},
|
||||
],
|
||||
actions: [{ action_name: 'send_message', action_params: [] }],
|
||||
};
|
||||
const errors = validateAutomation(automationWithInvalidActions);
|
||||
expect(errors).toHaveProperty('action_0');
|
||||
});
|
||||
|
||||
it('should not require action params for specific actions', () => {
|
||||
const automationWithNoParamAction = {
|
||||
name: 'Test',
|
||||
description: 'Test',
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'content',
|
||||
filter_operator: 'contains',
|
||||
values: 'hello',
|
||||
},
|
||||
],
|
||||
actions: [{ action_name: 'mute_conversation' }],
|
||||
};
|
||||
const errors = validateAutomation(automationWithNoParamAction);
|
||||
expect(errors).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
export const ATTRIBUTE_KEY_REQUIRED = 'ATTRIBUTE_KEY_REQUIRED';
|
||||
export const FILTER_OPERATOR_REQUIRED = 'FILTER_OPERATOR_REQUIRED';
|
||||
export const VALUE_REQUIRED = 'VALUE_REQUIRED';
|
||||
export const VALUE_MUST_BE_BETWEEN_1_AND_998 =
|
||||
'VALUE_MUST_BE_BETWEEN_1_AND_998';
|
||||
export const ACTION_PARAMETERS_REQUIRED = 'ACTION_PARAMETERS_REQUIRED';
|
||||
export const ATLEAST_ONE_CONDITION_REQUIRED = 'ATLEAST_ONE_CONDITION_REQUIRED';
|
||||
export const ATLEAST_ONE_ACTION_REQUIRED = 'ATLEAST_ONE_ACTION_REQUIRED';
|
||||
// ------------------------------------------------------------------
|
||||
// ------------------------ Filter Validation -----------------------
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates a single filter for conversations or contacts.
|
||||
*
|
||||
* @param {Object} filter - The filter object to validate.
|
||||
* @param {string} filter.attribute_key - The key of the attribute to filter on.
|
||||
* @param {string} filter.filter_operator - The operator to use for filtering.
|
||||
* @param {string|number|Array} [filter.values] - The value(s) to filter by (required for most operators).
|
||||
*
|
||||
* @returns {string|null} An error message if validation fails, or null if validation passes.
|
||||
*/
|
||||
const validateSingleFilter = filter => {
|
||||
if (!filter.attribute_key) {
|
||||
return ATTRIBUTE_KEY_REQUIRED;
|
||||
}
|
||||
|
||||
if (!filter.filter_operator) {
|
||||
return FILTER_OPERATOR_REQUIRED;
|
||||
}
|
||||
|
||||
if (
|
||||
filter.filter_operator !== 'is_present' &&
|
||||
filter.filter_operator !== 'is_not_present' &&
|
||||
(!filter.values ||
|
||||
(Array.isArray(filter.values) && filter.values.length === 0))
|
||||
) {
|
||||
return VALUE_REQUIRED;
|
||||
}
|
||||
|
||||
if (
|
||||
filter.filter_operator === 'days_before' &&
|
||||
(parseInt(filter.values, 10) <= 0 || parseInt(filter.values, 10) >= 999)
|
||||
) {
|
||||
return VALUE_MUST_BE_BETWEEN_1_AND_998;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates filters for conversations or contacts.
|
||||
*
|
||||
* @param {Array} filters - An array of filter objects to validate.
|
||||
* @param {string} filters[].attribute_key - The key of the attribute to filter on.
|
||||
* @param {string} filters[].filter_operator - The operator to use for filtering.
|
||||
* @param {string|number} [filters[].values] - The value(s) to filter by (required for most operators).
|
||||
*
|
||||
* @returns {Object} An object containing any validation errors, keyed by filter index.
|
||||
*/
|
||||
export const validateConversationOrContactFilters = filters => {
|
||||
const errors = {};
|
||||
|
||||
filters.forEach((filter, index) => {
|
||||
const error = validateSingleFilter(filter);
|
||||
if (error) {
|
||||
errors[`filter_${index}`] = error;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// ---------------------- Automation Validation ---------------------
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validates the basic fields of an automation object.
|
||||
*
|
||||
* @param {Object} automation - The automation object to validate.
|
||||
* @returns {Object} An object containing any validation errors.
|
||||
*/
|
||||
const validateBasicFields = automation => {
|
||||
const errors = {};
|
||||
const requiredFields = ['name', 'description', 'event_name'];
|
||||
|
||||
requiredFields.forEach(field => {
|
||||
if (!automation[field]) {
|
||||
errors[field] = `${
|
||||
field.charAt(0).toUpperCase() + field.slice(1)
|
||||
} is required`;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the conditions of an automation object.
|
||||
*
|
||||
* @param {Array} conditions - The conditions to validate.
|
||||
* @returns {Object} An object containing any validation errors.
|
||||
*/
|
||||
export const validateConditions = conditions => {
|
||||
const errors = {};
|
||||
|
||||
if (!conditions || conditions.length === 0) {
|
||||
errors.conditions = ATLEAST_ONE_CONDITION_REQUIRED;
|
||||
return errors;
|
||||
}
|
||||
|
||||
conditions.forEach((condition, index) => {
|
||||
const error = validateSingleFilter(condition);
|
||||
if (error) {
|
||||
errors[`condition_${index}`] = error;
|
||||
}
|
||||
});
|
||||
|
||||
return errors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates a single action of an automation object.
|
||||
*
|
||||
* @param {Object} action - The action to validate.
|
||||
* @returns {string|null} An error message if validation fails, or null if validation passes.
|
||||
*/
|
||||
const validateSingleAction = action => {
|
||||
const noParamActions = [
|
||||
'mute_conversation',
|
||||
'snooze_conversation',
|
||||
'resolve_conversation',
|
||||
'remove_assigned_team',
|
||||
];
|
||||
|
||||
if (
|
||||
!noParamActions.includes(action.action_name) &&
|
||||
(!action.action_params || action.action_params.length === 0)
|
||||
) {
|
||||
return ACTION_PARAMETERS_REQUIRED;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the actions of an automation object.
|
||||
*
|
||||
* @param {Array} actions - The actions to validate.
|
||||
* @returns {Object} An object containing any validation errors.
|
||||
*/
|
||||
export const validateActions = actions => {
|
||||
if (!actions || actions.length === 0) {
|
||||
return { actions: ATLEAST_ONE_ACTION_REQUIRED };
|
||||
}
|
||||
|
||||
return actions.reduce((errors, action, index) => {
|
||||
const error = validateSingleAction(action);
|
||||
if (error) {
|
||||
errors[`action_${index}`] = error;
|
||||
}
|
||||
return errors;
|
||||
}, {});
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates an automation object.
|
||||
*
|
||||
* @param {Object} automation - The automation object to validate.
|
||||
* @param {string} automation.name - The name of the automation.
|
||||
* @param {string} automation.description - The description of the automation.
|
||||
* @param {string} automation.event_name - The name of the event that triggers the automation.
|
||||
* @param {Array} automation.conditions - An array of condition objects for the automation.
|
||||
* @param {string} automation.conditions[].filter_operator - The operator for the condition.
|
||||
* @param {string|number} [automation.conditions[].values] - The value(s) for the condition.
|
||||
* @param {Array} automation.actions - An array of action objects for the automation.
|
||||
* @param {string} automation.actions[].action_name - The name of the action.
|
||||
* @param {Array} [automation.actions[].action_params] - The parameters for the action.
|
||||
*
|
||||
* @returns {Object} An object containing any validation errors.
|
||||
*/
|
||||
export const validateAutomation = automation => {
|
||||
const basicErrors = validateBasicFields(automation);
|
||||
const conditionErrors = validateConditions(automation.conditions);
|
||||
const actionErrors = validateActions(automation.actions);
|
||||
|
||||
return {
|
||||
...basicErrors,
|
||||
...conditionErrors,
|
||||
...actionErrors,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user