Files
upstream-rocketchat/apps/meteor/client/navbar/NavBarSearch/hooks/useAISearchItems.spec.ts
upstream-mirror 30810eecd7
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Code scanning - action / CodeQL-Build (push) Failing after 2s
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
2026-09-16 01:21:02 +05:00

333 lines
12 KiB
TypeScript

import { AI_SEARCH_FILTER_SUGGESTION_LIMIT, AI_SEARCH_PAGE_SIZE, emptySearchFilters } from '@rocket.chat/ai-search';
import { UserStatus } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import type { AISearchResult } from '@rocket.chat/rest-typings';
import { act, renderHook, waitFor } from '@testing-library/react';
import { useAISearchItems } from './useAISearchItems';
import { createFakeSubscription } from '../../../../tests/mocks/data';
let mockDebounce: (value: unknown) => unknown = (value) => value;
jest.mock('@rocket.chat/fuselage-hooks', () => ({
...jest.requireActual('@rocket.chat/fuselage-hooks'),
useDebouncedValue: (value: unknown) => mockDebounce(value),
}));
type AutocompleteUser = {
_id: string;
name: string;
username: string;
nickname: string;
status: UserStatus;
avatarETag: string;
};
const setup = ({
subscriptions = [],
intelligent = [],
users = [],
}: {
subscriptions?: ReturnType<typeof createFakeSubscription>[];
intelligent?: AISearchResult[];
users?: AutocompleteUser[];
} = {}) => {
const aiSearchHandler = jest.fn(() => ({
intelligent,
meta: { intelligentSearchEnabled: true, intelligentSearchConfigured: true, answerGenerationConfigured: true },
}));
const usersAutocompleteHandler = jest.fn(() => ({ items: users }));
const wrapper = mockAppRoot()
.withTranslations('en', 'core', {
Search_in_this_room: 'Search in this room',
Search_messages_from_this_username: 'Search messages from this username',
Search_messages_from_this_user: 'Search messages from this user',
Today: 'Today',
Yesterday: 'Yesterday',
Last_7_days: 'Last 7 days',
})
.withSubscriptions(subscriptions)
.withEndpoint('GET', '/v1/ai.search', aiSearchHandler)
.withEndpoint('GET', '/v1/users.autocomplete', usersAutocompleteHandler)
.build();
return { wrapper, aiSearchHandler, usersAutocompleteHandler };
};
const formatDate = (date: Date): string =>
`${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
beforeEach(() => {
mockDebounce = (value) => value;
});
describe('useAISearchItems', () => {
describe('when AI search is inactive', () => {
it('passes the filter text through untouched and yields no items', async () => {
const { wrapper, aiSearchHandler, usersAutocompleteHandler } = setup();
const { result } = renderHook(
() => useAISearchItems('in:general deploy', { ...emptySearchFilters(), roomNames: ['support'] }, false),
{ wrapper },
);
await act(async () => undefined);
expect(result.current.data.searchText).toBe('in:general deploy');
expect(result.current.data.intelligent).toEqual([]);
expect(result.current.data.filterSuggestions).toEqual([]);
expect(result.current.isFetching).toBe(false);
expect(aiSearchHandler).not.toHaveBeenCalled();
expect(usersAutocompleteHandler).not.toHaveBeenCalled();
});
it('defaults to inactive when only the filter text is provided', async () => {
const { wrapper, aiSearchHandler } = setup();
const { result } = renderHook(() => useAISearchItems('deploy'), { wrapper });
await act(async () => undefined);
expect(result.current.data.searchText).toBe('deploy');
expect(aiSearchHandler).not.toHaveBeenCalled();
});
});
describe('intelligent search', () => {
it('strips inline filter tokens and queries ai.search with the parsed filters', async () => {
const intelligent = [{ _id: 'msg-1', msgId: 'msg-1', text: 'Deployment failed' }];
const { wrapper, aiSearchHandler } = setup({ intelligent });
const { result } = renderHook(
() => useAISearchItems('in:general from:john after:2026-01-01 before:2026-02-01 deploy failed', emptySearchFilters(), true),
{ wrapper },
);
expect(result.current.data.searchText).toBe('deploy failed');
expect(result.current.isFetching).toBe(true);
await waitFor(() => expect(result.current.data.intelligent).toEqual(intelligent));
expect(result.current.isFetching).toBe(false);
expect(aiSearchHandler).toHaveBeenCalledTimes(1);
expect(aiSearchHandler).toHaveBeenCalledWith({
query: 'deploy failed',
intelligentCount: AI_SEARCH_PAGE_SIZE,
roomNames: 'general',
fromUsernames: 'john',
startDate: '2026-01-01',
endDate: '2026-02-01',
});
});
it('merges applied filters with inline filter tokens in the request', async () => {
const { wrapper, aiSearchHandler } = setup();
const { result } = renderHook(
() =>
useAISearchItems(
'in:general hello',
{ ...emptySearchFilters(), roomNames: ['support'], fromUsernames: ['jane'], startDate: '2026-05-01' },
true,
),
{ wrapper },
);
await waitFor(() => expect(aiSearchHandler).toHaveBeenCalled());
expect(aiSearchHandler).toHaveBeenCalledWith({
query: 'hello',
intelligentCount: AI_SEARCH_PAGE_SIZE,
roomNames: 'support,general',
fromUsernames: 'jane',
startDate: '2026-05-01',
});
await waitFor(() => expect(result.current.isFetching).toBe(false));
});
it('does not hit ai.search when only filter tokens are present', async () => {
const { wrapper, aiSearchHandler } = setup();
const { result } = renderHook(() => useAISearchItems('in:general ', emptySearchFilters(), true), { wrapper });
await act(async () => undefined);
expect(result.current.data.searchText).toBe('');
expect(result.current.data.intelligent).toEqual([]);
expect(aiSearchHandler).not.toHaveBeenCalled();
});
it('withholds intelligent results while the debounced search text lags behind', async () => {
mockDebounce = () => 'stale';
const intelligent = [{ _id: 'msg-1', text: 'Old result' }];
const { wrapper, aiSearchHandler } = setup({ intelligent });
const { result } = renderHook(() => useAISearchItems('deploy', emptySearchFilters(), true), { wrapper });
await waitFor(() => expect(aiSearchHandler).toHaveBeenCalledWith(expect.objectContaining({ query: 'stale' })));
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data.intelligent).toEqual([]);
});
});
describe('room filter suggestions', () => {
it('suggests rooms while an in: token is being typed', async () => {
const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', fname: 'General', t: 'c' })];
const { wrapper, aiSearchHandler } = setup({ subscriptions });
const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper });
await act(async () => undefined);
expect(result.current.data.searchText).toBe('');
expect(aiSearchHandler).not.toHaveBeenCalled();
expect(result.current.data.filterSuggestions).toEqual([
{
key: 'in-rid-general',
group: 'rooms',
title: '#General',
description: 'Search in this room',
value: 'in:general ',
icon: 'hash',
},
]);
});
it('caps room suggestions at the configured limit', async () => {
const subscriptions = Array.from({ length: AI_SEARCH_FILTER_SUGGESTION_LIMIT + 2 }, (_, index) =>
createFakeSubscription({ rid: `rid-${index}`, name: `general-${index}`, t: 'c' }),
);
const { wrapper } = setup({ subscriptions });
const { result } = renderHook(() => useAISearchItems('in:gen', emptySearchFilters(), true), { wrapper });
await act(async () => undefined);
expect(result.current.data.filterSuggestions).toHaveLength(AI_SEARCH_FILTER_SUGGESTION_LIMIT);
});
it('does not suggest rooms without an active in: token', async () => {
const subscriptions = [createFakeSubscription({ rid: 'rid-general', name: 'general', t: 'c' })];
const { wrapper } = setup({ subscriptions });
const { result } = renderHook(() => useAISearchItems('general stuff', emptySearchFilters(), true), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data.filterSuggestions).toEqual([]);
});
});
describe('user filter suggestions', () => {
const john: AutocompleteUser = {
_id: 'user-1',
name: 'John Doe',
username: 'john',
nickname: '',
status: UserStatus.ONLINE,
avatarETag: '',
};
it('suggests usernames from users.autocomplete while a from: token is active', async () => {
const { wrapper, usersAutocompleteHandler } = setup({ users: [john] });
const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper });
await waitFor(() =>
expect(result.current.data.filterSuggestions).toEqual([
{
key: 'from-user-1',
group: 'users',
title: '@john',
description: 'John Doe',
value: 'from:john ',
icon: 'user',
},
{
key: 'from-current',
group: 'users',
title: 'from:jo',
description: 'Search messages from this username',
value: 'from:jo ',
icon: 'user',
},
]),
);
expect(usersAutocompleteHandler).toHaveBeenCalledWith({
selector: JSON.stringify({ term: 'jo', conditions: {}, exceptions: [] }),
});
});
it('withholds username suggestions while the debounced from: value lags behind', async () => {
mockDebounce = (value) => (value === 'jo' ? '' : value);
const { wrapper, usersAutocompleteHandler } = setup({ users: [john] });
const { result } = renderHook(() => useAISearchItems('from:jo', emptySearchFilters(), true), { wrapper });
await waitFor(() => expect(usersAutocompleteHandler).toHaveBeenCalled());
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data.filterSuggestions).toEqual([
{
key: 'from-current',
group: 'users',
title: 'from:jo',
description: 'Search messages from this username',
value: 'from:jo ',
icon: 'user',
},
]);
});
});
describe('date filter suggestions', () => {
// freeze the clock so the hook's `new Date()` and the expected values below agree even across midnight
beforeEach(() => {
jest.useFakeTimers({ now: new Date('2026-07-15T12:00:00Z'), advanceTimers: true });
});
afterEach(() => {
jest.useRealTimers();
});
it('offers date suggestions for a trailing after: token', async () => {
const { wrapper } = setup();
const { result } = renderHook(() => useAISearchItems('after:', emptySearchFilters(), true), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
const today = new Date();
const yesterday = new Date(today);
yesterday.setDate(today.getDate() - 1);
const lastWeek = new Date(today);
lastWeek.setDate(today.getDate() - 7);
expect(result.current.data.filterSuggestions).toEqual([
{
key: `after-${formatDate(today)}`,
group: 'dates',
title: `after:${formatDate(today)}`,
description: 'Today',
value: `after:${formatDate(today)} `,
icon: 'calendar',
},
{
key: `after-${formatDate(yesterday)}`,
group: 'dates',
title: `after:${formatDate(yesterday)}`,
description: 'Yesterday',
value: `after:${formatDate(yesterday)} `,
icon: 'calendar',
},
{
key: `after-${formatDate(lastWeek)}`,
group: 'dates',
title: `after:${formatDate(lastWeek)}`,
description: 'Last 7 days',
value: `after:${formatDate(lastWeek)} `,
icon: 'calendar',
},
]);
});
it('offers date suggestions for a trailing before: token', async () => {
const { wrapper } = setup();
const { result } = renderHook(() => useAISearchItems('before:', emptySearchFilters(), true), { wrapper });
await waitFor(() => expect(result.current.isFetching).toBe(false));
expect(result.current.data.filterSuggestions).toHaveLength(3);
expect(result.current.data.filterSuggestions.every(({ key, group }) => key.startsWith('before-') && group === 'dates')).toBe(true);
});
});
});