chore: Add cache to improve widget performance (#11163)
- Add dynamic importing for routes. - Added caching for `campaign`, `articles` and `inbox_members` API end points. --------- Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
@@ -25,11 +25,6 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
// Handled separately for Twilio Inbox where phone number is not mandatory.
|
||||
// You can send message to a contact with Messaging Service Id.
|
||||
if (!phoneNumber) {
|
||||
return name;
|
||||
}
|
||||
return `${name} (${phoneNumber})`;
|
||||
}
|
||||
return name;
|
||||
|
||||
@@ -8,8 +8,8 @@ vi.mock('dashboard/api/contacts');
|
||||
describe('composeConversationHelper', () => {
|
||||
describe('generateLabelForContactableInboxesList', () => {
|
||||
const contact = {
|
||||
name: 'Priority Inbox',
|
||||
email: 'hello@example.com',
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
phoneNumber: '+1234567890',
|
||||
};
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('composeConversationHelper', () => {
|
||||
...contact,
|
||||
channelType: INBOX_TYPES.EMAIL,
|
||||
})
|
||||
).toBe('Priority Inbox (hello@example.com)');
|
||||
).toBe('John Doe (john@example.com)');
|
||||
});
|
||||
|
||||
it('generates label for twilio inbox', () => {
|
||||
@@ -28,14 +28,7 @@ describe('composeConversationHelper', () => {
|
||||
...contact,
|
||||
channelType: INBOX_TYPES.TWILIO,
|
||||
})
|
||||
).toBe('Priority Inbox (+1234567890)');
|
||||
|
||||
expect(
|
||||
helpers.generateLabelForContactableInboxesList({
|
||||
name: 'Priority Inbox',
|
||||
channelType: INBOX_TYPES.TWILIO,
|
||||
})
|
||||
).toBe('Priority Inbox');
|
||||
).toBe('John Doe (+1234567890)');
|
||||
});
|
||||
|
||||
it('generates label for whatsapp inbox', () => {
|
||||
@@ -44,7 +37,7 @@ describe('composeConversationHelper', () => {
|
||||
...contact,
|
||||
channelType: INBOX_TYPES.WHATSAPP,
|
||||
})
|
||||
).toBe('Priority Inbox (+1234567890)');
|
||||
).toBe('John Doe (+1234567890)');
|
||||
});
|
||||
|
||||
it('generates label for other inbox types', () => {
|
||||
@@ -53,7 +46,7 @@ describe('composeConversationHelper', () => {
|
||||
...contact,
|
||||
channelType: 'Channel::Api',
|
||||
})
|
||||
).toBe('Priority Inbox');
|
||||
).toBe('John Doe');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
43
app/javascript/shared/helpers/cache.js
Normal file
43
app/javascript/shared/helpers/cache.js
Normal file
@@ -0,0 +1,43 @@
|
||||
import { LocalStorage } from './localStorage';
|
||||
|
||||
// Default cache expiry is 24 hours
|
||||
const DEFAULT_EXPIRY = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const getFromCache = (key, expiry = DEFAULT_EXPIRY) => {
|
||||
try {
|
||||
const cached = LocalStorage.get(key);
|
||||
if (!cached) return null;
|
||||
|
||||
const { data, timestamp } = cached;
|
||||
const isExpired = Date.now() - timestamp > expiry;
|
||||
|
||||
if (isExpired) {
|
||||
LocalStorage.remove(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const setCache = (key, data) => {
|
||||
try {
|
||||
const cacheData = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
LocalStorage.set(key, cacheData);
|
||||
} catch (error) {
|
||||
// Ignore cache errors
|
||||
}
|
||||
};
|
||||
|
||||
export const clearCache = key => {
|
||||
try {
|
||||
LocalStorage.remove(key);
|
||||
} catch (error) {
|
||||
// Ignore cache errors
|
||||
}
|
||||
};
|
||||
136
app/javascript/shared/helpers/specs/cache.spec.js
Normal file
136
app/javascript/shared/helpers/specs/cache.spec.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { getFromCache, setCache, clearCache } from '../cache';
|
||||
import { LocalStorage } from '../localStorage';
|
||||
|
||||
vi.mock('../localStorage');
|
||||
|
||||
describe('Cache Helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2023, 1, 1, 0, 0, 0));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('getFromCache', () => {
|
||||
it('returns null when no data is cached', () => {
|
||||
LocalStorage.get.mockReturnValue(null);
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('returns cached data when not expired', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 1 hour ago
|
||||
const oneHourAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: oneHourAgo,
|
||||
});
|
||||
|
||||
// Default expiry is 24 hours
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toEqual({ foo: 'bar' });
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes and returns null when data is expired', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 25 hours ago (beyond the default 24-hour expiry)
|
||||
const twentyFiveHoursAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 25 * 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: twentyFiveHoursAgo,
|
||||
});
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('respects custom expiry time', () => {
|
||||
// Current time is 2023-02-01 00:00:00
|
||||
// Cache timestamp is 2 hours ago
|
||||
const twoHoursAgo =
|
||||
new Date(2023, 1, 1, 0, 0, 0).getTime() - 2 * 60 * 60 * 1000;
|
||||
|
||||
LocalStorage.get.mockReturnValue({
|
||||
data: { foo: 'bar' },
|
||||
timestamp: twoHoursAgo,
|
||||
});
|
||||
|
||||
// Set expiry to 1 hour
|
||||
const result = getFromCache('test-key', 60 * 60 * 1000);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(LocalStorage.get).toHaveBeenCalledWith('test-key');
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.get.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
const result = getFromCache('test-key');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setCache', () => {
|
||||
it('stores data with timestamp', () => {
|
||||
const data = { name: 'test' };
|
||||
const expectedCacheData = {
|
||||
data,
|
||||
timestamp: new Date(2023, 1, 1, 0, 0, 0).getTime(),
|
||||
};
|
||||
|
||||
setCache('test-key', data);
|
||||
|
||||
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||
'test-key',
|
||||
expectedCacheData
|
||||
);
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.set.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => setCache('test-key', { foo: 'bar' })).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearCache', () => {
|
||||
it('removes cached data', () => {
|
||||
clearCache('test-key');
|
||||
|
||||
expect(LocalStorage.remove).toHaveBeenCalledWith('test-key');
|
||||
});
|
||||
|
||||
it('handles errors gracefully', () => {
|
||||
LocalStorage.remove.mockImplementation(() => {
|
||||
throw new Error('Storage error');
|
||||
});
|
||||
|
||||
// Should not throw
|
||||
expect(() => clearCache('test-key')).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,5 @@
|
||||
import { createRouter, createWebHashHistory } from 'vue-router';
|
||||
import ViewWithHeader from './components/layouts/ViewWithHeader.vue';
|
||||
import UnreadMessages from './views/UnreadMessages.vue';
|
||||
import Campaigns from './views/Campaigns.vue';
|
||||
import Home from './views/Home.vue';
|
||||
import PreChatForm from './views/PreChatForm.vue';
|
||||
import Messages from './views/Messages.vue';
|
||||
import ArticleViewer from './views/ArticleViewer.vue';
|
||||
import store from './store';
|
||||
|
||||
const router = createRouter({
|
||||
@@ -14,12 +8,12 @@ const router = createRouter({
|
||||
{
|
||||
path: '/unread-messages',
|
||||
name: 'unread-messages',
|
||||
component: UnreadMessages,
|
||||
component: () => import('./views/UnreadMessages.vue'),
|
||||
},
|
||||
{
|
||||
path: '/campaigns',
|
||||
name: 'campaigns',
|
||||
component: Campaigns,
|
||||
component: () => import('./views/Campaigns.vue'),
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
@@ -28,22 +22,22 @@ const router = createRouter({
|
||||
{
|
||||
path: '',
|
||||
name: 'home',
|
||||
component: Home,
|
||||
component: () => import('./views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/prechat-form',
|
||||
name: 'prechat-form',
|
||||
component: PreChatForm,
|
||||
component: () => import('./views/PreChatForm.vue'),
|
||||
},
|
||||
{
|
||||
path: '/messages',
|
||||
name: 'messages',
|
||||
component: Messages,
|
||||
component: () => import('./views/Messages.vue'),
|
||||
},
|
||||
{
|
||||
path: '/article',
|
||||
name: 'article-viewer',
|
||||
component: ArticleViewer,
|
||||
component: () => import('./views/ArticleViewer.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAvailableAgents } from 'widget/api/agent';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
@@ -15,11 +16,22 @@ export const getters = {
|
||||
$state.records.filter(agent => agent.availability_status === 'online'),
|
||||
};
|
||||
|
||||
const CACHE_KEY_PREFIX = 'chatwoot_available_agents_';
|
||||
|
||||
export const actions = {
|
||||
fetchAvailableAgents: async ({ commit }, websiteToken) => {
|
||||
try {
|
||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${websiteToken}`);
|
||||
if (cachedData) {
|
||||
commit('setAgents', cachedData);
|
||||
commit('setError', false);
|
||||
commit('setHasFetched', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await getAvailableAgents(websiteToken);
|
||||
const { payload = [] } = data;
|
||||
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, payload);
|
||||
commit('setAgents', payload);
|
||||
commit('setError', false);
|
||||
commit('setHasFetched', true);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { getMostReadArticles } from 'widget/api/article';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
|
||||
const CACHE_KEY_PREFIX = 'chatwoot_most_read_articles_';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
@@ -20,9 +23,16 @@ export const actions = {
|
||||
commit('setError', false);
|
||||
|
||||
try {
|
||||
const cachedData = getFromCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`);
|
||||
if (cachedData) {
|
||||
commit('setArticles', cachedData);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data } = await getMostReadArticles(slug, locale);
|
||||
const { payload = [] } = data;
|
||||
|
||||
setCache(`${CACHE_KEY_PREFIX}${slug}_${locale}`, payload);
|
||||
if (payload.length) {
|
||||
commit('setArticles', payload);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,10 @@ import {
|
||||
formatCampaigns,
|
||||
filterCampaigns,
|
||||
} from 'widget/helpers/campaignHelper';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
|
||||
const CACHE_KEY_PREFIX = 'chatwoot_campaigns_';
|
||||
|
||||
const state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
@@ -41,7 +45,26 @@ export const actions = {
|
||||
{ websiteToken, currentURL, isInBusinessHours }
|
||||
) => {
|
||||
try {
|
||||
// Cache for 1 hour
|
||||
const CACHE_EXPIRY = 60 * 60 * 1000;
|
||||
const cachedData = getFromCache(
|
||||
`${CACHE_KEY_PREFIX}${websiteToken}`,
|
||||
CACHE_EXPIRY
|
||||
);
|
||||
if (cachedData) {
|
||||
commit('setCampaigns', cachedData);
|
||||
commit('setError', false);
|
||||
resetCampaignTimers(
|
||||
cachedData,
|
||||
currentURL,
|
||||
websiteToken,
|
||||
isInBusinessHours
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { data: campaigns } = await getCampaigns(websiteToken);
|
||||
setCache(`${CACHE_KEY_PREFIX}${websiteToken}`, campaigns);
|
||||
commit('setCampaigns', campaigns);
|
||||
commit('setError', false);
|
||||
resetCampaignTimers(
|
||||
|
||||
@@ -1,14 +1,60 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { actions } from '../../agent';
|
||||
import { agents } from './data';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
import { getAvailableAgents } from 'widget/api/agent';
|
||||
|
||||
const commit = vi.fn();
|
||||
let commit = vi.fn();
|
||||
vi.mock('widget/helpers/axios');
|
||||
|
||||
vi.mock('widget/api/agent');
|
||||
vi.mock('shared/helpers/cache');
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#fetchAvailableAgents', () => {
|
||||
const websiteToken = 'test-token';
|
||||
|
||||
beforeEach(() => {
|
||||
commit = vi.fn();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns cached data if available', async () => {
|
||||
getFromCache.mockReturnValue(agents);
|
||||
await actions.fetchAvailableAgents({ commit }, websiteToken);
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
`chatwoot_available_agents_${websiteToken}`
|
||||
);
|
||||
expect(getAvailableAgents).not.toHaveBeenCalled();
|
||||
expect(setCache).not.toHaveBeenCalled();
|
||||
expect(commit).toHaveBeenCalledWith('setAgents', agents);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
|
||||
});
|
||||
|
||||
it('fetches and caches data if no cache available', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
|
||||
|
||||
await actions.fetchAvailableAgents({ commit }, websiteToken);
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
`chatwoot_available_agents_${websiteToken}`
|
||||
);
|
||||
expect(getAvailableAgents).toHaveBeenCalledWith(websiteToken);
|
||||
expect(setCache).toHaveBeenCalledWith(
|
||||
`chatwoot_available_agents_${websiteToken}`,
|
||||
agents
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith('setAgents', agents);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
expect(commit).toHaveBeenCalledWith('setHasFetched', true);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is success', async () => {
|
||||
API.get.mockResolvedValue({ data: { payload: agents } });
|
||||
getFromCache.mockReturnValue(null);
|
||||
|
||||
getAvailableAgents.mockReturnValue({ data: { payload: agents } });
|
||||
await actions.fetchAvailableAgents({ commit }, 'Hi');
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setAgents', agents],
|
||||
@@ -17,7 +63,11 @@ describe('#actions', () => {
|
||||
]);
|
||||
});
|
||||
it('sends correct actions if API is error', async () => {
|
||||
API.get.mockRejectedValue({ message: 'Authentication required' });
|
||||
getFromCache.mockReturnValue(null);
|
||||
|
||||
getAvailableAgents.mockRejectedValue({
|
||||
message: 'Authentication required',
|
||||
});
|
||||
await actions.fetchAvailableAgents({ commit }, 'Hi');
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setError', true],
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { mutations, actions, getters } from '../../articles'; // update this import path to your actual module location
|
||||
import { getMostReadArticles } from 'widget/api/article';
|
||||
|
||||
vi.mock('widget/api/article');
|
||||
|
||||
describe('Vuex Articles Module', () => {
|
||||
let state;
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isError: false,
|
||||
hasFetched: false,
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('Mutations', () => {
|
||||
it('sets articles correctly', () => {
|
||||
const articles = [{ id: 1 }, { id: 2 }];
|
||||
mutations.setArticles(state, articles);
|
||||
expect(state.records).toEqual(articles);
|
||||
});
|
||||
|
||||
it('sets error flag correctly', () => {
|
||||
mutations.setError(state, true);
|
||||
expect(state.uiFlags.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('sets fetching state correctly', () => {
|
||||
mutations.setIsFetching(state, true);
|
||||
expect(state.uiFlags.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mutate records when no articles are provided', () => {
|
||||
const previousState = { ...state };
|
||||
mutations.setArticles(state, []);
|
||||
expect(state.records).toEqual(previousState.records);
|
||||
});
|
||||
|
||||
it('toggles the error state correctly', () => {
|
||||
mutations.setError(state, true);
|
||||
expect(state.uiFlags.isError).toBe(true);
|
||||
mutations.setError(state, false);
|
||||
expect(state.uiFlags.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the fetching state correctly', () => {
|
||||
mutations.setIsFetching(state, true);
|
||||
expect(state.uiFlags.isFetching).toBe(true);
|
||||
mutations.setIsFetching(state, false);
|
||||
expect(state.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
it('fetches articles correctly', async () => {
|
||||
const commit = vi.fn();
|
||||
const articles = [{ id: 1 }, { id: 2 }];
|
||||
getMostReadArticles.mockResolvedValueOnce({
|
||||
data: { payload: articles },
|
||||
});
|
||||
|
||||
await actions.fetch({ commit }, { slug: 'slug', locale: 'en' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
expect(commit).toHaveBeenCalledWith('setArticles', articles);
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
|
||||
it('handles fetch error correctly', async () => {
|
||||
const commit = vi.fn();
|
||||
getMostReadArticles.mockRejectedValueOnce(new Error('Error message'));
|
||||
|
||||
await actions.fetch(
|
||||
{ commit },
|
||||
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', true);
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
|
||||
it('does not mutate state when fetching returns an empty payload', async () => {
|
||||
const commit = vi.fn();
|
||||
getMostReadArticles.mockResolvedValueOnce({ data: { payload: [] } });
|
||||
|
||||
await actions.fetch({ commit }, { slug: 'slug', locale: 'en' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
|
||||
it('sets error state when fetching fails', async () => {
|
||||
const commit = vi.fn();
|
||||
getMostReadArticles.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await actions.fetch(
|
||||
{ commit },
|
||||
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', true);
|
||||
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getters', () => {
|
||||
it('returns uiFlags correctly', () => {
|
||||
const result = getters.uiFlags(state);
|
||||
expect(result).toEqual(state.uiFlags);
|
||||
});
|
||||
|
||||
it('returns popularArticles correctly', () => {
|
||||
const result = getters.popularArticles(state);
|
||||
expect(result).toEqual(state.records);
|
||||
});
|
||||
});
|
||||
});
|
||||
170
app/javascript/widget/store/modules/specs/articles.spec.js
Normal file
170
app/javascript/widget/store/modules/specs/articles.spec.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { mutations, actions, getters } from '../articles';
|
||||
import { getMostReadArticles } from 'widget/api/article';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
|
||||
vi.mock('widget/api/article');
|
||||
vi.mock('shared/helpers/cache');
|
||||
|
||||
describe('Vuex Articles Module', () => {
|
||||
let state;
|
||||
|
||||
beforeEach(() => {
|
||||
state = {
|
||||
records: [],
|
||||
uiFlags: {
|
||||
isError: false,
|
||||
hasFetched: false,
|
||||
isFetching: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('Mutations', () => {
|
||||
it('sets articles correctly', () => {
|
||||
const articles = [{ id: 1 }, { id: 2 }];
|
||||
mutations.setArticles(state, articles);
|
||||
expect(state.records).toEqual(articles);
|
||||
});
|
||||
|
||||
it('sets error flag correctly', () => {
|
||||
mutations.setError(state, true);
|
||||
expect(state.uiFlags.isError).toBe(true);
|
||||
});
|
||||
|
||||
it('sets fetching state correctly', () => {
|
||||
mutations.setIsFetching(state, true);
|
||||
expect(state.uiFlags.isFetching).toBe(true);
|
||||
});
|
||||
|
||||
it('does not mutate records when no articles are provided', () => {
|
||||
const previousState = { ...state };
|
||||
mutations.setArticles(state, []);
|
||||
expect(state.records).toEqual(previousState.records);
|
||||
});
|
||||
|
||||
it('toggles the error state correctly', () => {
|
||||
mutations.setError(state, true);
|
||||
expect(state.uiFlags.isError).toBe(true);
|
||||
mutations.setError(state, false);
|
||||
expect(state.uiFlags.isError).toBe(false);
|
||||
});
|
||||
|
||||
it('toggles the fetching state correctly', () => {
|
||||
mutations.setIsFetching(state, true);
|
||||
expect(state.uiFlags.isFetching).toBe(true);
|
||||
mutations.setIsFetching(state, false);
|
||||
expect(state.uiFlags.isFetching).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
describe('#fetch', () => {
|
||||
const slug = 'test-slug';
|
||||
const locale = 'en';
|
||||
const articles = [
|
||||
{ id: 1, title: 'Test' },
|
||||
{ id: 2, title: 'Test 2' },
|
||||
];
|
||||
let commit;
|
||||
|
||||
beforeEach(() => {
|
||||
commit = vi.fn();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns cached data if available', async () => {
|
||||
getFromCache.mockReturnValue(articles);
|
||||
|
||||
await actions.fetch({ commit }, { slug, locale });
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
`chatwoot_most_read_articles_${slug}_${locale}`
|
||||
);
|
||||
expect(getMostReadArticles).not.toHaveBeenCalled();
|
||||
expect(setCache).not.toHaveBeenCalled();
|
||||
expect(commit).toHaveBeenCalledWith('setArticles', articles);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
});
|
||||
|
||||
it('fetches and caches data if no cache available', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
|
||||
|
||||
await actions.fetch({ commit }, { slug, locale });
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
`chatwoot_most_read_articles_${slug}_${locale}`
|
||||
);
|
||||
expect(getMostReadArticles).toHaveBeenCalledWith(slug, locale);
|
||||
expect(setCache).toHaveBeenCalledWith(
|
||||
`chatwoot_most_read_articles_${slug}_${locale}`,
|
||||
articles
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith('setArticles', articles);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
});
|
||||
|
||||
it('handles API errors correctly', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
getMostReadArticles.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
await actions.fetch({ commit }, { slug, locale });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setError', true);
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
|
||||
it('does not mutate state when fetching returns an empty payload', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
getMostReadArticles.mockReturnValue({ data: { payload: [] } });
|
||||
|
||||
await actions.fetch({ commit }, { slug, locale });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', false);
|
||||
expect(commit).not.toHaveBeenCalledWith(
|
||||
'setArticles',
|
||||
expect.any(Array)
|
||||
);
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
|
||||
it('sets loading state during fetch', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
getMostReadArticles.mockReturnValue({ data: { payload: articles } });
|
||||
|
||||
await actions.fetch({ commit }, { slug, locale });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
});
|
||||
|
||||
it('sets error state when fetching fails', async () => {
|
||||
const commit = vi.fn();
|
||||
getMostReadArticles.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
await actions.fetch(
|
||||
{ commit },
|
||||
{ websiteToken: 'token', slug: 'slug', locale: 'en' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', true);
|
||||
expect(commit).toHaveBeenCalledWith('setError', true);
|
||||
expect(commit).not.toHaveBeenCalledWith('setArticles', expect.any(Array));
|
||||
expect(commit).toHaveBeenCalledWith('setIsFetching', false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Getters', () => {
|
||||
it('returns uiFlags correctly', () => {
|
||||
const result = getters.uiFlags(state);
|
||||
expect(result).toEqual(state.uiFlags);
|
||||
});
|
||||
|
||||
it('returns popularArticles correctly', () => {
|
||||
const result = getters.popularArticles(state);
|
||||
expect(result).toEqual(state.records);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,12 @@
|
||||
import { API } from 'widget/helpers/axios';
|
||||
import { actions } from '../../campaign';
|
||||
import { campaigns } from './data';
|
||||
import { getFromCache, setCache } from 'shared/helpers/cache';
|
||||
|
||||
const commit = vi.fn();
|
||||
const dispatch = vi.fn();
|
||||
vi.mock('widget/helpers/axios');
|
||||
vi.mock('shared/helpers/cache');
|
||||
|
||||
import campaignTimer from 'widget/helpers/campaignTimer';
|
||||
vi.mock('widget/helpers/campaignTimer', () => ({
|
||||
@@ -15,8 +17,17 @@ vi.mock('widget/helpers/campaignTimer', () => ({
|
||||
|
||||
describe('#actions', () => {
|
||||
describe('#fetchCampaigns', () => {
|
||||
it('sends correct actions if API is success', async () => {
|
||||
API.get.mockResolvedValue({ data: campaigns });
|
||||
beforeEach(() => {
|
||||
commit.mockClear();
|
||||
getFromCache.mockClear();
|
||||
setCache.mockClear();
|
||||
API.get.mockClear();
|
||||
campaignTimer.initTimers.mockClear();
|
||||
});
|
||||
|
||||
it('uses cached data when available', async () => {
|
||||
getFromCache.mockReturnValue(campaigns);
|
||||
|
||||
await actions.fetchCampaigns(
|
||||
{ commit },
|
||||
{
|
||||
@@ -25,6 +36,54 @@ describe('#actions', () => {
|
||||
isInBusinessHours: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
'chatwoot_campaigns_XDsafmADasd',
|
||||
60 * 60 * 1000
|
||||
);
|
||||
expect(API.get).not.toHaveBeenCalled();
|
||||
expect(setCache).not.toHaveBeenCalled();
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setCampaigns', campaigns],
|
||||
['setError', false],
|
||||
]);
|
||||
expect(campaignTimer.initTimers).toHaveBeenCalledWith(
|
||||
{
|
||||
campaigns: [
|
||||
{
|
||||
id: 11,
|
||||
timeOnPage: '20',
|
||||
url: 'https://chatwoot.com',
|
||||
triggerOnlyDuringBusinessHours: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
'XDsafmADasd'
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches and caches data when cache is not available', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
API.get.mockResolvedValue({ data: campaigns });
|
||||
|
||||
await actions.fetchCampaigns(
|
||||
{ commit },
|
||||
{
|
||||
websiteToken: 'XDsafmADasd',
|
||||
currentURL: 'https://chatwoot.com',
|
||||
isInBusinessHours: true,
|
||||
}
|
||||
);
|
||||
|
||||
expect(getFromCache).toHaveBeenCalledWith(
|
||||
'chatwoot_campaigns_XDsafmADasd',
|
||||
60 * 60 * 1000
|
||||
);
|
||||
expect(API.get).toHaveBeenCalled();
|
||||
expect(setCache).toHaveBeenCalledWith(
|
||||
'chatwoot_campaigns_XDsafmADasd',
|
||||
campaigns
|
||||
);
|
||||
expect(commit.mock.calls).toEqual([
|
||||
['setCampaigns', campaigns],
|
||||
['setError', false],
|
||||
@@ -43,7 +102,9 @@ describe('#actions', () => {
|
||||
'XDsafmADasd'
|
||||
);
|
||||
});
|
||||
|
||||
it('sends correct actions if API is error', async () => {
|
||||
getFromCache.mockReturnValue(null);
|
||||
API.get.mockRejectedValue({ message: 'Authentication required' });
|
||||
await actions.fetchCampaigns(
|
||||
{ commit },
|
||||
|
||||
Reference in New Issue
Block a user