## Description Adds the account-level `unread_count_for_filters` feature flag as the dark-launch gate for filtered sidebar unread counts. This reuses the deprecated `quoted_email_reply` flag slot, resets the reused bit for existing accounts, and removes stale defaults so new accounts do not reference the old flag. This also adds the feature where we are now calculating the unread counts for built in filters like mentions, participating and unattended along with unread count for saved filters/folders. Closes [CW-7262](https://linear.app/chatwoot/issue/CW-7262/unread-counts-for-filters-folders) ## Type of change - [ ] Bug fix (non-breaking change which fixes an issue) - [x] New feature (non-breaking change which adds functionality) - [ ] Breaking change (fix or feature that would cause existing functionality not to work as expected) - [ ] This change requires a documentation update
381 lines
11 KiB
JavaScript
381 lines
11 KiB
JavaScript
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
|
import ActionCableConnector from '../actionCable';
|
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
|
|
|
vi.mock('shared/helpers/mitt', () => ({
|
|
emitter: {
|
|
emit: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
vi.mock('dashboard/composables/useImpersonation', () => ({
|
|
useImpersonation: () => ({
|
|
isImpersonating: { value: false },
|
|
}),
|
|
}));
|
|
|
|
global.chatwootConfig = {
|
|
websocketURL: 'wss://test.chatwoot.com',
|
|
};
|
|
|
|
const mockRetryJitter = value =>
|
|
vi.spyOn(Math, 'random').mockReturnValue(value);
|
|
|
|
describe('ActionCableConnector - Copilot Tests', () => {
|
|
let store;
|
|
let actionCable;
|
|
let mockDispatch;
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockDispatch = vi.fn();
|
|
store = {
|
|
$store: {
|
|
dispatch: mockDispatch,
|
|
getters: {
|
|
getCurrentAccountId: 1,
|
|
'accounts/isFeatureEnabledonAccount': vi.fn(() => true),
|
|
},
|
|
},
|
|
};
|
|
|
|
actionCable = ActionCableConnector.init(store.$store, 'test-token');
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
vi.clearAllTimers();
|
|
vi.useRealTimers();
|
|
});
|
|
describe('copilot event handlers', () => {
|
|
it('should register the copilot.message.created event handler', () => {
|
|
expect(Object.keys(actionCable.events)).toContain(
|
|
'copilot.message.created'
|
|
);
|
|
expect(actionCable.events['copilot.message.created']).toBe(
|
|
actionCable.onCopilotMessageCreated
|
|
);
|
|
});
|
|
|
|
it('should handle the copilot.message.created event through the ActionCable system', () => {
|
|
const copilotData = {
|
|
id: 2,
|
|
content: 'This is a copilot message from ActionCable',
|
|
conversation_id: 456,
|
|
created_at: '2025-05-27T15:58:04-06:00',
|
|
account_id: 1,
|
|
};
|
|
actionCable.onReceived({
|
|
event: 'copilot.message.created',
|
|
data: copilotData,
|
|
});
|
|
expect(mockDispatch).toHaveBeenCalledWith(
|
|
'copilotMessages/upsert',
|
|
copilotData
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('conversation unread count event handlers', () => {
|
|
it('should register the conversation.unread_count_changed event handler', () => {
|
|
expect(Object.keys(actionCable.events)).toContain(
|
|
'conversation.unread_count_changed'
|
|
);
|
|
expect(actionCable.events['conversation.unread_count_changed']).toBe(
|
|
actionCable.onConversationUnreadCountChanged
|
|
);
|
|
});
|
|
|
|
it('should refetch unread counts when unread count changes', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
mockRetryJitter(0.5);
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
|
|
|
vi.advanceTimersByTime(37499);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
|
expect(mockDispatch).toHaveBeenLastCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
});
|
|
|
|
it('does not retry unread count changes when filtered counts are disabled', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
store.$store.getters[
|
|
'accounts/isFeatureEnabledonAccount'
|
|
].mockImplementation(
|
|
(_, featureFlag) =>
|
|
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
|
);
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(45000);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('delays unread count refetch when a conversation is mentioned', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
|
|
const conversation = { id: 1, account_id: 1 };
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.mentioned',
|
|
data: conversation,
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
|
|
vi.advanceTimersByTime(4999);
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(mockDispatch).toHaveBeenCalledWith('conversationUnreadCounts/get');
|
|
});
|
|
|
|
it('does not schedule mention unread count fetches when filtered counts are disabled', () => {
|
|
vi.useFakeTimers();
|
|
store.$store.getters[
|
|
'accounts/isFeatureEnabledonAccount'
|
|
].mockImplementation(
|
|
(_, featureFlag) =>
|
|
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
|
);
|
|
|
|
const conversation = { id: 1, account_id: 1 };
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.mentioned',
|
|
data: conversation,
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledWith('addMentions', conversation);
|
|
|
|
vi.advanceTimersByTime(45000);
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
});
|
|
|
|
it('retries mentioned unread counts after the backend refresh window', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
mockRetryJitter(0.5);
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.mentioned',
|
|
data: { id: 1, account_id: 1 },
|
|
});
|
|
|
|
const unreadCountFetches = () =>
|
|
mockDispatch.mock.calls.filter(
|
|
([action]) => action === 'conversationUnreadCounts/get'
|
|
);
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
expect(unreadCountFetches()).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(32499);
|
|
expect(unreadCountFetches()).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(unreadCountFetches()).toHaveLength(2);
|
|
});
|
|
|
|
it('reschedules mentioned unread count retries for later invalidations', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
mockRetryJitter(0);
|
|
|
|
const unreadCountFetches = () =>
|
|
mockDispatch.mock.calls.filter(
|
|
([action]) => action === 'conversationUnreadCounts/get'
|
|
);
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.mentioned',
|
|
data: { id: 1, account_id: 1 },
|
|
});
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
expect(unreadCountFetches()).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(10000);
|
|
actionCable.onReceived({
|
|
event: 'conversation.mentioned',
|
|
data: { id: 1, account_id: 1 },
|
|
});
|
|
|
|
vi.advanceTimersByTime(5000);
|
|
expect(unreadCountFetches()).toHaveLength(2);
|
|
|
|
vi.advanceTimersByTime(10000);
|
|
expect(unreadCountFetches()).toHaveLength(2);
|
|
|
|
vi.advanceTimersByTime(15000);
|
|
expect(unreadCountFetches()).toHaveLength(3);
|
|
});
|
|
|
|
it('refetches filtered unread counts after account cache invalidation', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
mockRetryJitter(0.5);
|
|
|
|
const cacheKeys = {
|
|
label: 'label-key',
|
|
inbox: 'inbox-key',
|
|
team: 'team-key',
|
|
};
|
|
const unreadCountFetches = () =>
|
|
mockDispatch.mock.calls.filter(
|
|
([action]) => action === 'conversationUnreadCounts/get'
|
|
);
|
|
|
|
actionCable.onReceived({
|
|
event: 'account.cache_invalidated',
|
|
data: { account_id: 1, cache_keys: cacheKeys },
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledWith('labels/revalidate', {
|
|
newKey: cacheKeys.label,
|
|
});
|
|
expect(mockDispatch).toHaveBeenCalledWith('inboxes/revalidate', {
|
|
newKey: cacheKeys.inbox,
|
|
});
|
|
expect(mockDispatch).toHaveBeenCalledWith('teams/revalidate', {
|
|
newKey: cacheKeys.team,
|
|
});
|
|
expect(unreadCountFetches()).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(37499);
|
|
expect(unreadCountFetches()).toHaveLength(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(unreadCountFetches()).toHaveLength(2);
|
|
});
|
|
|
|
it('does not refetch unread counts after cache invalidation when filtered counts are disabled', () => {
|
|
vi.useFakeTimers();
|
|
store.$store.getters[
|
|
'accounts/isFeatureEnabledonAccount'
|
|
].mockImplementation(
|
|
(_, featureFlag) =>
|
|
featureFlag === FEATURE_FLAGS.CONVERSATION_UNREAD_COUNTS
|
|
);
|
|
|
|
actionCable.onReceived({
|
|
event: 'account.cache_invalidated',
|
|
data: {
|
|
account_id: 1,
|
|
cache_keys: {
|
|
label: 'label-key',
|
|
inbox: 'inbox-key',
|
|
team: 'team-key',
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
|
|
vi.advanceTimersByTime(45000);
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
});
|
|
|
|
it('does not refetch unread counts when unread count feature is disabled', () => {
|
|
store.$store.getters[
|
|
'accounts/isFeatureEnabledonAccount'
|
|
].mockReturnValue(false);
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
expect(mockDispatch).not.toHaveBeenCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
});
|
|
|
|
it('should throttle unread count refetches for repeated events', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(4999);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(1);
|
|
|
|
vi.advanceTimersByTime(1);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
|
expect(mockDispatch).toHaveBeenLastCalledWith(
|
|
'conversationUnreadCounts/get'
|
|
);
|
|
});
|
|
|
|
it('clears pending unread count refetch before immediate refetch', () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:00Z'));
|
|
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
vi.advanceTimersByTime(1000);
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
vi.setSystemTime(new Date('2026-01-01T00:00:06Z'));
|
|
actionCable.onReceived({
|
|
event: 'conversation.unread_count_changed',
|
|
data: { account_id: 1 },
|
|
});
|
|
|
|
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
|
|
|
vi.advanceTimersByTime(4000);
|
|
expect(mockDispatch).toHaveBeenCalledTimes(2);
|
|
});
|
|
});
|
|
});
|