diff --git a/src/channel.ts b/src/channel.ts index e748eb7a4..497febdd4 100644 --- a/src/channel.ts +++ b/src/channel.ts @@ -62,6 +62,7 @@ import type { QueryMembersOptions, Reaction, ReactionAPIResponse, + RequestOptions, SearchAPIResponse, SearchMessageSortBase, SearchOptions, @@ -377,6 +378,7 @@ export class Channel { * @param {MemberSort} [sort] Sort options, for instance [{created_at: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{name: -1}, {created_at: 1}] * @param {{ limit?: number; offset?: number }} [options] Option object, {limit: 10, offset:10} + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} Query Members response */ @@ -384,6 +386,7 @@ export class Channel { filterConditions: MemberFilters, sort: MemberSort = [], options: QueryMembersOptions = {}, + requestOptions: RequestOptions = {}, ) { let id: string | undefined; const type = this.type; @@ -406,6 +409,7 @@ export class Channel { ...options, }, }, + requestOptions, ); } diff --git a/src/client.ts b/src/client.ts index 012f8436b..95706f256 100644 --- a/src/client.ts +++ b/src/client.ts @@ -219,6 +219,7 @@ import type { ReminderAPIResponse, RemoveUserGroupMembersOptions, RemoveUserGroupMembersResponse, + RequestOptions, ReviewFlagReportOptions, ReviewFlagReportResponse, SdkIdentifier, @@ -1312,6 +1313,9 @@ export class StreamChat { // eslint-disable-next-line @typescript-eslint/no-explicit-any } catch (e: any /**TODO: generalize error types */) { e.client_request_id = requestConfig.headers?.['x-client-request-id']; + // An aborted request is a deliberate cancellation, not a failure - logging it + // and counting it towards the token-refresh backoff would be misleading. + if (axios.isCancel(e)) throw e; this._logApiError(type, url, e); this.consecutiveFailures += 1; if (e.response) { @@ -1333,16 +1337,22 @@ export class StreamChat { } }; - get(url: string, params?: AxiosRequestConfig['params']) { - return this.doAxiosRequest('get', url, null, { params }); + get( + url: string, + params?: AxiosRequestConfig['params'], + // `params` is omitted deliberately: _enrichAxiosOptions spreads `config` after the + // enriched params, so a `config.params` would drop api_key/user_id/connection_id. + config?: Omit, + ) { + return this.doAxiosRequest('get', url, null, { params, config }); } put(url: string, data?: unknown) { return this.doAxiosRequest('put', url, data); } - post(url: string, data?: unknown) { - return this.doAxiosRequest('post', url, data); + post(url: string, data?: unknown, config?: Omit) { + return this.doAxiosRequest('post', url, data, { config }); } patch(url: string, data?: unknown) { @@ -1837,6 +1847,7 @@ export class StreamChat { * @param {UserSort} sort Sort options, for instance [{last_active: -1}]. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_active: -1}, {created_at: 1}] * @param {UserOptions} options Option object, {presence: true} + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise<{ users: Array }>} User Query Response */ @@ -1844,6 +1855,7 @@ export class StreamChat { filterConditions: UserFilters, sort: UserSort = [], options: UserOptions = {}, + requestOptions: RequestOptions = {}, ) { const defaultOptions = { presence: false, @@ -1867,6 +1879,7 @@ export class StreamChat { ...options, }, }, + requestOptions, ); this.state.updateUsers(data.users); @@ -1918,13 +1931,17 @@ export class StreamChat { * searchUserGroups - Search user groups by prefix for autocomplete * * @param {SearchUserGroupsOptions} options The search options - * + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * @return {Promise} User Group Search Response */ - async searchUserGroups(options: SearchUserGroupsOptions) { + async searchUserGroups( + options: SearchUserGroupsOptions, + requestOptions: RequestOptions = {}, + ) { return await this.get( this.baseURL + '/usergroups/search', options, + requestOptions, ); } @@ -2061,6 +2078,7 @@ export class StreamChat { * @param {ChannelSort} [sort] Sort options, for instance {created_at: -1}. * When using multiple fields, make sure you use array of objects to guarantee field order, for instance [{last_updated: -1}, {created_at: 1}] * @param {ChannelOptions} [options] Options object. Can include predefined_filter, filter_values, and sort_values for using predefined filters. + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} full search channels response */ @@ -2068,6 +2086,7 @@ export class StreamChat { filterConditions: ChannelFilters, sort: ChannelSort = [], options: ChannelOptions = {}, + requestOptions: RequestOptions = {}, ): Promise { const defaultOptions: ChannelOptions = { state: true, @@ -2101,7 +2120,11 @@ export class StreamChat { ...restOptions, }; - return await this.post(this.baseURL + '/channels', payload); + return await this.post( + this.baseURL + '/channels', + payload, + requestOptions, + ); } /** @@ -2147,6 +2170,7 @@ export class StreamChat { * - stateOptions.skipInitialization - Skips the initialization of the state for the channels matching the ids in the list. * - stateOptions.skipHydration - Skips returning the channels as instances of the Channel class and rather returns the raw query response. * - stateOptions.withResponse - Returns the full query response with hydrated channels. This is a compatibility bridge for internal callers that need response-level metadata while the default return value remains `Channel[]`. + * - stateOptions.signal - Aborts the request. See AbortController. * * @return {Promise>} search channels response */ @@ -2172,6 +2196,8 @@ export class StreamChat { filterConditions, sort, options, + // stateOptions is never serialized, so it carries the abort signal + { signal: stateOptions.signal }, ); const channels = queryChannelsResponse.channels; @@ -2317,6 +2343,7 @@ export class StreamChat { * @param {ChannelFilters} filterConditions MongoDB style filter conditions * @param {MessageFilters | string} query search query or object MongoDB style filters * @param {SearchOptions} [options] Option object, {user_id: 'tommaso'} + * @param {RequestOptions} [requestOptions] Carries an abort signal. Not sent in the request. * * @return {Promise} search messages response */ @@ -2324,6 +2351,7 @@ export class StreamChat { filterConditions: ChannelFilters, query: string | MessageFilters, options: SearchOptions = {}, + requestOptions: RequestOptions = {}, ) { if (options.offset && options.next) { throw Error(`Cannot specify offset with next`); @@ -2346,7 +2374,11 @@ export class StreamChat { // Make sure we wait for the connect promise if there is a pending one await this.wsPromise; - return await this.get(this.baseURL + '/search', { payload }); + return await this.get( + this.baseURL + '/search', + { payload }, + requestOptions, + ); } /** @@ -3806,6 +3838,12 @@ export class StreamChat { ...axiosRequestConfigRest } = this.options.axiosRequestConfig || {}; + // Most specific wins, and it is spread last so that a config carrying an explicit + // `signal: undefined` cannot clobber a client-wide one or an armed + // nextRequestAbortController. + const resolvedSignal = + options.config?.signal ?? axiosRequestConfigRest.signal ?? signal; + return { params: { user_id: this.userID, @@ -3821,9 +3859,9 @@ export class StreamChat { ...options.headers, ...(axiosRequestConfigHeaders || {}), }, - ...(signal ? { signal } : {}), ...options.config, - ...(axiosRequestConfigRest || {}), + ...axiosRequestConfigRest, + ...(resolvedSignal ? { signal: resolvedSignal } : {}), }; } @@ -3993,8 +4031,12 @@ export class StreamChat { * * @returns {Promise} */ - searchRoles(options: SearchRolesOptions) { - return this.get(`${this.baseURL}/roles/search`, options); + searchRoles(options: SearchRolesOptions, requestOptions: RequestOptions = {}) { + return this.get( + `${this.baseURL}/roles/search`, + options, + requestOptions, + ); } /** deleteRole - deletes a custom role diff --git a/src/messageComposer/middleware/textComposer/mentions.ts b/src/messageComposer/middleware/textComposer/mentions.ts index 096566e8c..47599fec6 100644 --- a/src/messageComposer/middleware/textComposer/mentions.ts +++ b/src/messageComposer/middleware/textComposer/mentions.ts @@ -9,7 +9,11 @@ import { userSuggestionToMentionEntity, userSuggestionToUserResponse, } from './mentionUtils'; -import { BaseSearchSource, type SearchSourceOptions } from '../../../search'; +import { + BaseSearchSource, + type SearchQueryOptions, + type SearchSourceOptions, +} from '../../../search'; import { mergeWith } from '../../../utils/mergeWith'; import type { ChannelMentionSuggestion, @@ -92,6 +96,13 @@ export const calculateLevenshtein = (query: string, name: string) => { }; export type MentionsSearchSourceOptions = SearchSourceOptions & { + /** Static base filters for the app-wide user query (mentionAllAppUsers). */ + userFilters?: UserFilters; + userSort?: UserSort; + /** Static base filters for the channel member query. */ + memberFilters?: MemberFilters; + memberSort?: MemberSort; + searchOptions?: Omit; mentionAllAppUsers?: boolean; suggestionFactoryMappers?: MentionSuggestionFactoryMapperOverrides; textComposerText?: string; @@ -299,20 +310,34 @@ export class MentionsSearchSource extends BaseSearchSource { userSort: UserSort | undefined; memberSort: MemberSort | undefined; // todo: document there are filters and sort options for users and members searchOptions: Omit | undefined; - config: MentionsSearchSourceOptions; + config: Pick< + MentionsSearchSourceOptions, + 'mentionAllAppUsers' | 'suggestionFactoryMappers' | 'textComposerText' | 'trigger' + >; constructor(channel: Channel, options?: MentionsSearchSourceOptions) { const { mentionAllAppUsers, + memberFilters, + memberSort, + searchOptions, suggestionFactoryMappers, textComposerText, transliterate, trigger, + userFilters, + userSort, ...restOptions } = options || {}; - super(restOptions); + // suggestions are shown for a bare trigger, so the empty query must be allowed + super({ ...restOptions, allowEmptySearchString: true }); this.client = channel.getClient(); this.channel = channel; + this.userFilters = userFilters; + this.userSort = userSort; + this.memberFilters = memberFilters; + this.memberSort = memberSort; + this.searchOptions = searchOptions; this.config = { mentionAllAppUsers, suggestionFactoryMappers, @@ -425,11 +450,6 @@ export class MentionsSearchSource extends BaseSearchSource { }; } - canExecuteQuery = (newSearchString?: string) => { - const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - return this.isActive && !this.isLoading && (hasNewSearchQuery || this.hasNext); - }; - protected updatePaginationStateFromQuery() { const userPaginationState = this.latestUserPaginationState ?? { itemCount: 0 }; @@ -472,10 +492,13 @@ export class MentionsSearchSource extends BaseSearchSource { : []), ].filter(({ name }) => this.matchesPrefixSearchQuery(name, searchQuery)); - getRoleMentionSuggestions = async (query: string): Promise => { + getRoleMentionSuggestions = async ( + query: string, + queryOptions: SearchQueryOptions = {}, + ): Promise => { if (!this.isMentionTypeAllowed('role')) return []; if (!query) return []; - const { roles } = await this.client.searchRoles({ query }); + const { roles } = await this.client.searchRoles({ query }, queryOptions); return [...(roles?.map((role) => role.name) ?? [])] .sort((left, right) => left.localeCompare(right)) .map((role) => this.toRoleMentionSuggestion(role, query)); @@ -558,23 +581,40 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - queryUsers = async (searchQuery: string, offset = 0) => { + queryUsers = async ( + searchQuery: string, + offset = 0, + queryOptions: SearchQueryOptions = {}, + ) => { const { filters, sort, options } = this.prepareQueryUsersParams(searchQuery, offset); - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers(filters, sort, options, queryOptions); return users; }; - queryMembers = async (searchQuery: string, offset = 0) => { + queryMembers = async ( + searchQuery: string, + offset = 0, + queryOptions: SearchQueryOptions = {}, + ) => { const { filters, sort, options } = this.prepareQueryMembersParams( searchQuery, offset, ); - const response = await this.channel.queryMembers(filters, sort, options); + const response = await this.channel.queryMembers( + filters, + sort, + options, + queryOptions, + ); return response.members.map((member) => member.user) as UserResponse[]; }; - getUserSuggestionsPage = async (searchQuery: string, userOffset = 0) => { + getUserSuggestionsPage = async ( + searchQuery: string, + userOffset = 0, + queryOptions: SearchQueryOptions = {}, + ) => { if (!this.isMentionTypeAllowed('user')) { return { items: [], @@ -587,7 +627,7 @@ export class MentionsSearchSource extends BaseSearchSource { this.allMembersLoadedWithInitialChannelQuery || !searchQuery; if (this.config.mentionAllAppUsers) { - users = await this.queryUsers(searchQuery, userOffset); + users = await this.queryUsers(searchQuery, userOffset, queryOptions); } else if (shouldSearchLocally) { const localUsers = this.searchMembersLocally(searchQuery); const items = localUsers @@ -601,7 +641,7 @@ export class MentionsSearchSource extends BaseSearchSource { : undefined, }; } else { - users = await this.queryMembers(searchQuery, userOffset); + users = await this.queryMembers(searchQuery, userOffset, queryOptions); } const items = users.map((user) => this.toUserSuggestion(user, searchQuery)); @@ -623,7 +663,11 @@ export class MentionsSearchSource extends BaseSearchSource { } satisfies UserGroupSearchCursor); }; - getUserGroupSuggestionsPage = async (searchQuery: string, cursor?: string) => { + getUserGroupSuggestionsPage = async ( + searchQuery: string, + cursor?: string, + queryOptions: SearchQueryOptions = {}, + ) => { if (!this.isMentionTypeAllowed('user_group')) { return { items: [], @@ -647,7 +691,7 @@ export class MentionsSearchSource extends BaseSearchSource { ...(userGroupCursor?.id_gt ? { id_gt: userGroupCursor.id_gt } : {}), ...(userGroupCursor?.name_gt ? { name_gt: userGroupCursor.name_gt } : {}), }; - const { user_groups } = await this.client.searchUserGroups(options); + const { user_groups } = await this.client.searchUserGroups(options, queryOptions); return { items: user_groups.map((userGroup) => @@ -657,20 +701,28 @@ export class MentionsSearchSource extends BaseSearchSource { }; }; - async query(searchQuery: string) { + async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const userOffset = this.offset ?? 0; const isFirstPage = userOffset === 0 && typeof this.userGroupCursor === 'undefined'; const previousUserPaginationState = this.latestUserPaginationState; const previousUserGroupCursor = this.userGroupCursor; const [userResultsState, userGroupResultsState, roleSuggestionsState] = await Promise.allSettled([ - this.getUserSuggestionsPage(searchQuery, userOffset), - this.getUserGroupSuggestionsPage(searchQuery, previousUserGroupCursor), + this.getUserSuggestionsPage(searchQuery, userOffset, queryOptions), + this.getUserGroupSuggestionsPage( + searchQuery, + previousUserGroupCursor, + queryOptions, + ), isFirstPage - ? this.getRoleMentionSuggestions(searchQuery) + ? this.getRoleMentionSuggestions(searchQuery, queryOptions) : Promise.resolve([] as RoleMentionSuggestion[]), ]); + // On abort the requests above reject and the fallback branches below would write + // empty results into the pagination cursors, corrupting them for the newer query. + if (queryOptions.signal?.aborted) return { items: [] }; + const userResults = userResultsState.status === 'fulfilled' ? userResultsState.value diff --git a/src/search/BaseSearchSource.ts b/src/search/BaseSearchSource.ts index 6b5f9a28d..73eb8e0ed 100644 --- a/src/search/BaseSearchSource.ts +++ b/src/search/BaseSearchSource.ts @@ -9,10 +9,24 @@ import type { import type { APIError } from '../errors'; import { isAPIError, isErrorRetryable } from '../errors'; +/** + * Passed to a search source's `query()`. Carries a cancellation handle and makes no + * claim that the query performs a request: a source may resolve from local data and + * ignore the signal. Where a source does issue requests, forward this straight through + * as the request options. + */ +export type SearchQueryOptions = { + /** Aborted once a newer query supersedes this one. */ + signal?: AbortSignal; +}; + export type DebounceOptions = { - debounceMs: number; + /** Applies to both short and long queries unless overridden by the options below. */ + debounceMs?: number; + shortQueryDebounceMs?: number; + longQueryDebounceMs?: number; + shortQueryMaxLength?: number; }; -type DebouncedExecQueryFunction = DebouncedFunc<(searchString?: string) => Promise>; // eslint-disable-next-line @typescript-eslint/no-explicit-any interface ISearchSource { @@ -54,18 +68,44 @@ export interface SearchSourceSync extends ISearchSource { search(text?: string): void; } -const DEFAULT_SEARCH_SOURCE_OPTIONS: Required = { - debounceMs: 300, +// Debounce defaults are resolved by resolveDebounceOptions, not here. +const DEFAULT_SEARCH_SOURCE_OPTIONS: Required< + Omit +> = { pageSize: 10, allowEmptySearchString: false, resetOnNewSearchQuery: true, } as const; -abstract class BaseSearchSourceBase implements ISearchSource { +/** + * An explicit `debounceMs` applies to both buckets, so integrators who already tuned it + * keep exactly their configured interval. Only when it is absent do the 500/300 defaults + * kick in. + */ +const resolveDebounceOptions = ({ + debounceMs, + shortQueryDebounceMs, + longQueryDebounceMs, + shortQueryMaxLength, +}: DebounceOptions) => ({ + shortQueryDebounceMs: shortQueryDebounceMs ?? debounceMs ?? 500, + longQueryDebounceMs: longQueryDebounceMs ?? debounceMs ?? 300, + shortQueryMaxLength: shortQueryMaxLength ?? 2, +}); + +abstract class BaseSearchSourceBase< + T, + R extends void | Promise, +> implements ISearchSource { state: StateStore>; pageSize: number; protected allowEmptySearchString: boolean; protected resetOnNewSearchQuery: boolean; + // assigned by setDebounceOptions, which the constructor always calls + protected shortQueryDebounceMs!: number; + protected longQueryDebounceMs!: number; + protected shortQueryMaxLength!: number; + protected searchDebounced!: DebouncedFunc<(searchString?: string) => R>; abstract readonly type: SearchSourceType; protected constructor(options?: SearchSourceOptions) { @@ -77,8 +117,44 @@ abstract class BaseSearchSourceBase implements ISearchSource { this.allowEmptySearchString = allowEmptySearchString; this.resetOnNewSearchQuery = resetOnNewSearchQuery; this.state = new StateStore>(this.initialState); + // Field initializers run before this body, so setDebounceOptions is already + // assigned; it only captures getDebounceMs lazily. + this.setDebounceOptions(options ?? {}); + } + + abstract executeQuery(newSearchString?: string): R; + + setDebounceOptions = (options: DebounceOptions = {}) => { + const resolved = resolveDebounceOptions(options); + this.shortQueryDebounceMs = resolved.shortQueryDebounceMs; + this.longQueryDebounceMs = resolved.longQueryDebounceMs; + this.shortQueryMaxLength = resolved.shortQueryMaxLength; + this.searchDebounced = debounce(this.executeQuery.bind(this), (searchString) => + this.getDebounceMs(searchString), + ); + }; + + /** + * Short queries match too much and are slow server-side, so they get a longer debounce. + * Resolved per call, which is what lets the interval switch as the user keeps typing. + */ + protected getDebounceMs = (searchString?: string) => { + const { length } = searchString ?? this.searchQuery; + return length <= this.shortQueryMaxLength + ? this.shortQueryDebounceMs + : this.longQueryDebounceMs; + }; + + /** + * A new search query preempts an in-flight one; pagination waits for it to finish + * and needs a next page to fetch. + */ + protected canDispatchQuery(hasNewSearchQuery: boolean) { + return hasNewSearchQuery || (!this.isLoading && this.hasNext); } + search = (searchQuery?: string) => this.searchDebounced(searchQuery); + get lastQueryError() { return this.state.getLatestValue().lastQueryError; } @@ -99,7 +175,7 @@ abstract class BaseSearchSourceBase implements ISearchSource { return this.state.getLatestValue().isLoading; } - get initialState() { + get initialState(): SearchSourceState { return { hasNext: true, isActive: false, @@ -143,8 +219,7 @@ abstract class BaseSearchSourceBase implements ISearchSource { const searchString = newSearchString ?? this.searchQuery; return !!( this.isActive && - !this.isLoading && - (this.hasNext || hasNewSearchQuery) && + this.canDispatchQuery(hasNewSearchQuery) && (this.allowEmptySearchString || searchString) ); }; @@ -217,34 +292,48 @@ abstract class BaseSearchSourceBase implements ISearchSource { } export abstract class BaseSearchSource - extends BaseSearchSourceBase + extends BaseSearchSourceBase> implements SearchSource { - protected searchDebounced!: DebouncedExecQueryFunction; + /** Aborts the in-flight request, if any, once a newer query is dispatched. */ + protected queryAbortController: AbortController | null = null; - constructor(options?: SearchSourceOptions) { - const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options }; - super(options); - this.setDebounceOptions({ debounceMs }); - } - - protected abstract query(searchQuery: string): Promise>; + protected abstract query( + searchQuery: string, + options?: SearchQueryOptions, + ): Promise>; protected abstract filterQueryResults(items: T[]): T[] | Promise; - setDebounceOptions = ({ debounceMs }: DebounceOptions) => { - this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs); - }; + /** Aborts the in-flight request, if any. Returns true when one was aborted. */ + protected abortInFlightQuery() { + if (!this.queryAbortController) return false; + this.queryAbortController.abort(); + this.queryAbortController = null; + return true; + } + + resetState() { + // otherwise an in-flight response would repopulate the state we just cleared + this.abortInFlightQuery(); + super.resetState(); + } async executeQuery(newSearchString?: string) { + // Checked before anything is dispatched, so that the abort below always has a + // successor that will clear isLoading. if (!this.canExecuteQuery(newSearchString)) return; + // cancel the previous request before dispatching the new one + this.abortInFlightQuery(); + const { signal } = (this.queryAbortController = new AbortController()); + const { hasNewSearchQuery, searchString } = this.prepareStateForQuery(newSearchString); let stateUpdate: Partial> = {}; try { - const results = await this.query(searchString); + const results = await this.query(searchString, { signal }); if (!results) return; const { items } = results; @@ -256,37 +345,34 @@ export abstract class BaseSearchSource stateUpdate.hasNext = false; } } finally { - this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); + // A newer query owns the state now - publishing here would clobber it with a + // stale result (and with the abort error). + if (!signal.aborted) { + this.state.next(this.getStateAfterQuery(stateUpdate, hasNewSearchQuery)); + } } } - search = (searchQuery?: string) => this.searchDebounced(searchQuery); - cancelScheduledQuery() { this.searchDebounced.cancel(); + // Nothing will dispatch a successor query, so release the loading state the + // aborted query owned - its own finally block skips the state write. + if (this.abortInFlightQuery() && this.isLoading) { + this.state.partialNext({ isLoading: false }); + } } } +// Queries are resolved locally, so there is nothing to cancel here - only the +// dynamic debounce applies. export abstract class BaseSearchSourceSync - extends BaseSearchSourceBase + extends BaseSearchSourceBase implements SearchSourceSync { - protected searchDebounced!: DebouncedExecQueryFunction; - - constructor(options?: SearchSourceOptions) { - const { debounceMs } = { ...DEFAULT_SEARCH_SOURCE_OPTIONS, ...options }; - super(options); - this.setDebounceOptions({ debounceMs }); - } - protected abstract query(searchQuery: string): QueryReturnValue; protected abstract filterQueryResults(items: T[]): T[]; - setDebounceOptions = ({ debounceMs }: DebounceOptions) => { - this.searchDebounced = debounce(this.executeQuery.bind(this), debounceMs); - }; - executeQuery(newSearchString?: string) { if (!this.canExecuteQuery(newSearchString)) return; @@ -311,8 +397,6 @@ export abstract class BaseSearchSourceSync } } - search = (searchQuery?: string) => this.searchDebounced(searchQuery); - cancelScheduledQuery() { this.searchDebounced.cancel(); } diff --git a/src/search/ChannelMemberSearchSource.ts b/src/search/ChannelMemberSearchSource.ts index f851d8c9c..f4abe92d0 100644 --- a/src/search/ChannelMemberSearchSource.ts +++ b/src/search/ChannelMemberSearchSource.ts @@ -1,4 +1,4 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { Channel } from '../channel'; import type { @@ -15,6 +15,13 @@ export type ChannelMemberSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type ChannelMemberSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: MemberFilters; + sort?: MemberSort; + searchOptions?: Omit; +}; + export class ChannelMemberSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -30,14 +37,19 @@ export class ChannelMemberSearchSource< constructor( channel: Channel, - options?: SearchSourceOptions, + options?: ChannelMemberSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< MemberFilters, ChannelMemberSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + // members are listed with an empty query, so the initial load must be allowed + super({ ...restOptions, allowEmptySearchString: true }); this.channel = channel; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< MemberFilters, ChannelMemberSearchSourceFilterBuilderContext @@ -60,13 +72,7 @@ export class ChannelMemberSearchSource< }); } - canExecuteQuery = (newSearchString?: string) => { - const hasNewSearchQuery = typeof newSearchString !== 'undefined'; - - return this.isActive && !this.isLoading && (this.hasNext || hasNewSearchQuery); - }; - - protected async query(searchQuery: string) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { @@ -75,7 +81,12 @@ export class ChannelMemberSearchSource< }); const sort = this.sort ?? []; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { members } = await this.channel.queryMembers(filters ?? {}, sort, options); + const { members } = await this.channel.queryMembers( + filters ?? {}, + sort, + options, + queryOptions, + ); return { items: members }; } diff --git a/src/search/ChannelSearchSource.ts b/src/search/ChannelSearchSource.ts index 8bf172933..c0c4a972f 100644 --- a/src/search/ChannelSearchSource.ts +++ b/src/search/ChannelSearchSource.ts @@ -1,4 +1,4 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import type { FilterBuilderOptions } from '../pagination'; import { FilterBuilder } from '../pagination'; import type { Channel } from '../channel'; @@ -12,6 +12,13 @@ export type ChannelSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type ChannelSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: ChannelFilters; + sort?: ChannelSort; + searchOptions?: Omit; +}; + export class ChannelSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -27,14 +34,18 @@ export class ChannelSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: ChannelSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< ChannelFilters, ChannelSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + super(restOptions); this.client = client; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< ChannelFilters, ChannelSearchSourceFilterBuilderContext @@ -51,7 +62,7 @@ export class ChannelSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: { ...(this.client.userID ? { members: { $in: [this.client.userID] } } : {}), @@ -63,7 +74,7 @@ export class ChannelSearchSource< }); const sort = this.sort ?? {}; const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const items = await this.client.queryChannels(filters, sort, options); + const items = await this.client.queryChannels(filters, sort, options, queryOptions); return { items }; } diff --git a/src/search/MessageSearchSource.ts b/src/search/MessageSearchSource.ts index 63c9c3377..69d87e8f3 100644 --- a/src/search/MessageSearchSource.ts +++ b/src/search/MessageSearchSource.ts @@ -1,4 +1,4 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import type { ChannelFilters, ChannelOptions, @@ -54,6 +54,18 @@ export type MessageSearchSourceFilterBuilderOptions< >; }>; +export type MessageSearchSourceOptions = SearchSourceOptions & { + /** Static base filters for the channel scope of the message search. */ + messageSearchChannelFilters?: ChannelFilters; + /** Static base filters for the message search itself. */ + messageSearchFilters?: MessageFilters; + messageSearchSort?: SearchMessageSort; + /** Static base filters for the follow-up query that hydrates unknown channels. */ + channelQueryFilters?: ChannelFilters; + channelQuerySort?: ChannelSort; + channelQueryOptions?: Omit; +}; + export class MessageSearchSource< TContexts extends MessageSearchSourceContexts = {}, > extends BaseSearchSource { @@ -86,11 +98,26 @@ export class MessageSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: MessageSearchSourceOptions, filterBuilderOptions?: MessageSearchSourceFilterBuilderOptions, ) { - super(options); + const { + messageSearchChannelFilters, + messageSearchFilters, + messageSearchSort, + channelQueryFilters, + channelQuerySort, + channelQueryOptions, + ...restOptions + } = options || {}; + super(restOptions); this.client = client; + this.messageSearchChannelFilters = messageSearchChannelFilters; + this.messageSearchFilters = messageSearchFilters; + this.messageSearchSort = messageSearchSort; + this.channelQueryFilters = channelQueryFilters; + this.channelQuerySort = channelQuerySort; + this.channelQueryOptions = channelQueryOptions; this.messageSearchChannelFilterBuilder = new FilterBuilder< ChannelFilters, @@ -129,7 +156,7 @@ export class MessageSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { if (!this.client.userID || this.next === null) return { items: [] }; const channelFilters = this.messageSearchChannelFilterBuilder.buildFilters({ @@ -170,9 +197,13 @@ export class MessageSearchSource< channelFilters, messageFilters, options, + queryOptions, ); const items = results.map(({ message }) => message); + // a newer query already replaced this one - skip the cid scan and the hydration request + if (queryOptions.signal?.aborted) return { items, next }; + const cids = Array.from( items.reduce((acc, message) => { if (message.cid && !this.client.activeChannels[message.cid]) acc.add(message.cid); @@ -194,6 +225,7 @@ export class MessageSearchSource< ...this.channelQuerySort, }, this.channelQueryOptions, + queryOptions, ); } diff --git a/src/search/UserSearchSource.ts b/src/search/UserSearchSource.ts index 335073c8a..baee5416c 100644 --- a/src/search/UserSearchSource.ts +++ b/src/search/UserSearchSource.ts @@ -1,4 +1,4 @@ -import { BaseSearchSource } from './BaseSearchSource'; +import { BaseSearchSource, type SearchQueryOptions } from './BaseSearchSource'; import { FilterBuilder, type FilterBuilderOptions } from '../pagination'; import type { StreamChat } from '../client'; import type { UserFilters, UserOptions, UserResponse, UserSort } from '../types'; @@ -10,6 +10,13 @@ export type UserSearchSourceFilterBuilderContext< C extends CustomContext = CustomContext, > = { searchQuery?: string } & C; +export type UserSearchSourceOptions = SearchSourceOptions & { + /** Static base filters merged under the dynamically generated ones. */ + filters?: UserFilters; + sort?: UserSort; + searchOptions?: Omit; +}; + export class UserSearchSource< TFilterContext extends CustomContext = CustomContext, > extends BaseSearchSource { @@ -25,14 +32,18 @@ export class UserSearchSource< constructor( client: StreamChat, - options?: SearchSourceOptions, + options?: UserSearchSourceOptions, filterBuilderOptions: FilterBuilderOptions< UserFilters, UserSearchSourceFilterBuilderContext > = {}, ) { - super(options); + const { filters, sort, searchOptions, ...restOptions } = options || {}; + super(restOptions); this.client = client; + this.filters = filters; + this.sort = sort; + this.searchOptions = searchOptions; this.filterBuilder = new FilterBuilder< UserFilters, UserSearchSourceFilterBuilderContext @@ -55,7 +66,7 @@ export class UserSearchSource< }); } - protected async query(searchQuery: string) { + protected async query(searchQuery: string, queryOptions: SearchQueryOptions = {}) { const filters = this.filterBuilder.buildFilters({ baseFilters: this.filters, context: { searchQuery } as UserSearchSourceFilterBuilderContext, @@ -68,7 +79,7 @@ export class UserSearchSource< sort = { id: 1, ...this.sort }; } const options = { ...this.searchOptions, limit: this.pageSize, offset: this.offset }; - const { users } = await this.client.queryUsers(filters, sort, options); + const { users } = await this.client.queryUsers(filters, sort, options, queryOptions); return { items: users }; } diff --git a/src/search/types.ts b/src/search/types.ts index f1708df17..3d7ffff30 100644 --- a/src/search/types.ts +++ b/src/search/types.ts @@ -11,8 +11,21 @@ export type SearchSourceState = { }; export type SearchSourceOptions = { - /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ + /** + * Legacy single debounce interval. When set, it applies to both short and long queries, + * unless overridden by `shortQueryDebounceMs` / `longQueryDebounceMs`. + */ debounceMs?: number; + /** + * Debounce interval for queries no longer than `shortQueryMaxLength`. Such queries have + * low selectivity and are expensive server-side, so they are debounced harder. + * Defaults to 500ms. + */ + shortQueryDebounceMs?: number; + /** Debounce interval for queries longer than `shortQueryMaxLength`. Defaults to 300ms. */ + longQueryDebounceMs?: number; + /** Query length (inclusive) that still counts as short. Defaults to 2. */ + shortQueryMaxLength?: number; pageSize?: number; /** When true, the source can execute queries with an empty search string. Defaults to false. */ allowEmptySearchString?: boolean; diff --git a/src/types.ts b/src/types.ts index b2125b50f..8be30925e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1189,7 +1189,13 @@ export type ChannelQueryOptions = { watchers?: PaginationOptions; }; -export type ChannelStateOptions = { +/** + * Composed with RequestOptions because `queryChannels` carries the abort signal in this + * bag; neither the state flags nor the signal are serialized into the request. Declaring + * the composition keeps passing a RequestOptions here intentional rather than incidental + * structural compatibility. + */ +export type ChannelStateOptions = RequestOptions & { offlineMode?: boolean; skipInitialization?: string[]; skipHydration?: boolean; @@ -1573,6 +1579,12 @@ export type SearchOptions = { sort?: SearchMessageSort; }; +/** Per-request options that are never part of the serialized request payload. */ +export type RequestOptions = { + /** Aborts the request. See AbortController. */ + signal?: AbortSignal; +}; + export type StreamChatOptions = AxiosRequestConfig & { /** * Used to disable warnings that are triggered by using connectUser or connectAnonymousUser server-side. diff --git a/src/utils.ts b/src/utils.ts index 0932b7fe3..9c515a96a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -723,11 +723,13 @@ export interface DebouncedFunc any> { flush(): ReturnType | undefined; } -// works exactly the same as lodash.debounce +// works exactly the same as lodash.debounce, except that the timeout can also be +// a function of the call arguments, resolved on every call (e.g. to debounce short, +// low-selectivity search queries harder than long ones) // eslint-disable-next-line @typescript-eslint/no-explicit-any export const debounce = any>( fn: T, - timeout = 0, + timeout: number | ((...args: Parameters) => number) = 0, { leading = false, trailing = true }: { leading?: boolean; trailing?: boolean } = {}, ): DebouncedFunc => { let runningTimeout: null | NodeJS.Timeout = null; @@ -750,7 +752,8 @@ export const debounce = any>( runningTimeout = null; }; - runningTimeout = setTimeout(timeoutHandler, timeout); + const delay = typeof timeout === 'function' ? timeout(...args) : timeout; + runningTimeout = setTimeout(timeoutHandler, delay); return lastResult; }; diff --git a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts index 35d11341e..65d99101a 100644 --- a/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts +++ b/test/unit/MessageComposer/middleware/textComposer/MentionsSearchSource.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, vi, type MockInstance } from 'vitest'; import { getAllowedMentionTypesFromCapabilities, MentionsSearchSource, @@ -339,12 +339,15 @@ describe('MentionsSearchSource', () => { const result = await source.query('adm'); - expect(client.searchUserGroups).toHaveBeenCalledWith({ - limit: 10, - query: 'adm', - team_id: 'engineering', - } satisfies SearchUserGroupsOptions); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }); + expect(client.searchUserGroups).toHaveBeenCalledWith( + { + limit: 10, + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions, + {}, + ); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }, {}); expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeDefined(); expect(getSuggestion(result.items, 'channel', 'channel')).toBeUndefined(); @@ -358,7 +361,7 @@ describe('MentionsSearchSource', () => { const result = await source.query('mod'); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'mod' }); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'mod' }, {}); expect(getSuggestion(result.items, 'role', 'moderator')).toBeDefined(); expect(getSuggestion(result.items, 'role', 'channel_moderator')).toBeDefined(); }); @@ -371,8 +374,8 @@ describe('MentionsSearchSource', () => { const firstResult = await source.query('adm'); const secondResult = await source.query('mod'); - expect(client.searchRoles).toHaveBeenNthCalledWith(1, { query: 'adm' }); - expect(client.searchRoles).toHaveBeenNthCalledWith(2, { query: 'mod' }); + expect(client.searchRoles).toHaveBeenNthCalledWith(1, { query: 'adm' }, {}); + expect(client.searchRoles).toHaveBeenNthCalledWith(2, { query: 'mod' }, {}); expect(getSuggestion(firstResult.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(firstResult.items, 'role', 'moderator')).toBeUndefined(); expect(getSuggestion(secondResult.items, 'role', 'moderator')).toBeDefined(); @@ -430,7 +433,7 @@ describe('MentionsSearchSource', () => { const result = await source.query('adm'); - expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }); + expect(client.searchRoles).toHaveBeenCalledWith({ query: 'adm' }, {}); expect(client.searchUserGroups).not.toHaveBeenCalled(); expect(getSuggestion(result.items, 'role', 'admin')).toBeDefined(); expect(getSuggestion(result.items, 'user_group', 'admins-group')).toBeUndefined(); @@ -554,8 +557,11 @@ describe('MentionsSearchSource', () => { source.activate(); expect(source.canExecuteQuery('test')).toBe(true); + // a new search query preempts an in-flight one source.state.partialNext({ isLoading: true }); - expect(source.canExecuteQuery('test')).toBe(false); + expect(source.canExecuteQuery('test')).toBe(true); + // ... but pagination still waits for it + expect(source.canExecuteQuery()).toBe(false); source.state.partialNext({ isLoading: false }); source.deactivate(); expect(source.canExecuteQuery('test')).toBe(false); @@ -581,18 +587,52 @@ describe('MentionsSearchSource', () => { expect.any(Object), expect.any(Object), expect.objectContaining({ limit: 10, offset: 3 }), + { signal: expect.any(AbortSignal) }, + ); + expect(client.searchUserGroups).toHaveBeenCalledWith( + { + id_gt: 'group-0', + limit: 10, + name_gt: 'Admins', + query: 'adm', + team_id: 'engineering', + } satisfies SearchUserGroupsOptions, + { signal: expect.any(AbortSignal) }, ); - expect(client.searchUserGroups).toHaveBeenCalledWith({ - id_gt: 'group-0', - limit: 10, - name_gt: 'Admins', - query: 'adm', - team_id: 'engineering', - } satisfies SearchUserGroupsOptions); expect(source.state.getLatestValue().next).toBeUndefined(); expect(source.state.getLatestValue().offset).toBe(7); }); + it('preserves pagination cursors when an aborted query rejects', async () => { + const source = new MentionsSearchSource(channel, { mentionAllAppUsers: true }); + source.activate(); + source.config.textComposerText = '@adm'; + const cursor = JSON.stringify({ id_gt: 'group-0', name_gt: 'Admins' }); + const internals = source as unknown as { + userGroupCursor?: string; + latestUserPaginationState?: { itemCount: number; nextOffset?: number }; + }; + internals.userGroupCursor = cursor; + internals.latestUserPaginationState = { itemCount: 4, nextOffset: 7 }; + + // an abort makes every sub-request reject, which would otherwise drive the + // fallback branches and overwrite the cursors with empty results + (client.queryUsers as unknown as MockInstance).mockRejectedValue( + new Error('canceled'), + ); + (client.searchUserGroups as unknown as MockInstance).mockRejectedValue( + new Error('canceled'), + ); + const controller = new AbortController(); + controller.abort(); + + const result = await source.query('adm', { signal: controller.signal }); + + expect(result.items).toEqual([]); + expect(internals.userGroupCursor).toBe(cursor); + expect(internals.latestUserPaginationState).toEqual({ itemCount: 4, nextOffset: 7 }); + }); + it('should correctly get members and watchers without duplicates', () => { const source = new MentionsSearchSource(channel); channel.state.watchers = { @@ -672,6 +712,7 @@ describe('MentionsSearchSource', () => { expect.any(Object), expect.any(Object), expect.objectContaining({ presence: true }), + {}, ); }); diff --git a/test/unit/predefined_filters.test.ts b/test/unit/predefined_filters.test.ts index e01ebca02..1a170a521 100644 --- a/test/unit/predefined_filters.test.ts +++ b/test/unit/predefined_filters.test.ts @@ -289,6 +289,7 @@ describe('Predefined Filters', () => { watch: true, presence: false, }), + { signal: undefined }, ); // Should NOT include filter_conditions when using predefined filter expect(postSpy).toHaveBeenCalledWith( @@ -296,6 +297,7 @@ describe('Predefined Filters', () => { expect.not.objectContaining({ filter_conditions: expect.anything(), }), + { signal: undefined }, ); }); @@ -324,6 +326,7 @@ describe('Predefined Filters', () => { sort_values: { sort_field: 'last_message_at' }, limit: 50, }), + { signal: undefined }, ); }); @@ -352,12 +355,14 @@ describe('Predefined Filters', () => { ], limit: 20, }), + { signal: undefined }, ); expect(postSpy).toHaveBeenCalledWith( `${client.baseURL}/channels`, expect.not.objectContaining({ filter_conditions: expect.anything(), }), + { signal: undefined }, ); }); @@ -382,6 +387,7 @@ describe('Predefined Filters', () => { sort: [{ field: 'last_message_at', direction: -1 }], limit: 20, }), + { signal: undefined }, ); // Should NOT include predefined_filter fields expect(postSpy).toHaveBeenCalledWith( @@ -389,6 +395,7 @@ describe('Predefined Filters', () => { expect.not.objectContaining({ predefined_filter: expect.anything(), }), + { signal: undefined }, ); }); @@ -411,6 +418,7 @@ describe('Predefined Filters', () => { expect.objectContaining({ watch: false, }), + { signal: undefined }, ); }); diff --git a/test/unit/requestAbortSignal.test.ts b/test/unit/requestAbortSignal.test.ts new file mode 100644 index 000000000..5b6db6137 --- /dev/null +++ b/test/unit/requestAbortSignal.test.ts @@ -0,0 +1,132 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import axios from 'axios'; + +import { getClientWithUser } from './test-utils/getClient'; +import type { StreamChat } from '../../src'; + +describe('per-request abort signal', () => { + let client: StreamChat; + let signal: AbortSignal; + + beforeEach(() => { + client = getClientWithUser({ id: 'user-1' }); + signal = new AbortController().signal; + }); + + describe('verb wrappers', () => { + beforeEach(() => { + // these reach _enrichAxiosOptions, which needs a token + vi.spyOn(client, '_getToken').mockReturnValue('token'); + }); + + it('forwards a config from get() to axios', async () => { + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', { payload: {} }, { signal }); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal }); + }); + + it('forwards a config from post() to axios', async () => { + const axiosPost = vi + .spyOn(client.axiosInstance, 'post') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.post('https://example.com/x', {}, { signal }); + + expect(axiosPost.mock.calls[0][2]).toMatchObject({ signal }); + }); + + it('takes precedence over a client-wide axiosRequestConfig signal', async () => { + const clientWideSignal = new AbortController().signal; + client.options.axiosRequestConfig = { signal: clientWideSignal }; + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', {}, { signal }); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal }); + }); + + it('falls back to the client-wide signal when no per-request one is given', async () => { + const clientWideSignal = new AbortController().signal; + client.options.axiosRequestConfig = { signal: clientWideSignal }; + const axiosGet = vi + .spyOn(client.axiosInstance, 'get') + .mockResolvedValue({ data: {}, status: 200 } as never); + + await client.get('https://example.com/x', {}); + + expect(axiosGet.mock.calls[0][1]).toMatchObject({ signal: clientWideSignal }); + }); + }); + + describe('cancelled requests', () => { + beforeEach(() => { + vi.spyOn(client, '_getToken').mockReturnValue('token'); + }); + + it('rethrows without logging or counting a failure', async () => { + const canceled = new axios.Cancel('canceled') as unknown as Error; + vi.spyOn(client.axiosInstance, 'get').mockRejectedValue(canceled); + const logApiError = vi.spyOn(client, '_logApiError'); + client.consecutiveFailures = 0; + + await expect(client.get('https://example.com/x', {})).rejects.toBe(canceled); + + // a deliberate cancellation is not an API failure + expect(logApiError).not.toHaveBeenCalled(); + expect(client.consecutiveFailures).toBe(0); + }); + + it('still logs and counts a genuine network failure', async () => { + vi.spyOn(client.axiosInstance, 'get').mockRejectedValue(new Error('ECONNRESET')); + const logApiError = vi.spyOn(client, '_logApiError'); + client.consecutiveFailures = 0; + + await expect(client.get('https://example.com/x', {})).rejects.toThrow('ECONNRESET'); + + expect(logApiError).toHaveBeenCalled(); + expect(client.consecutiveFailures).toBe(1); + }); + }); + + describe('query endpoints', () => { + it('passes the signal through search()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ results: [] } as never); + + await client.search({}, 'hello', {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through queryUsers()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ users: [] } as never); + + await client.queryUsers({}, [], {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through queryChannels()', async () => { + const post = vi.spyOn(client, 'post').mockResolvedValue({ channels: [] } as never); + + await client.queryChannels({}, [], {}, { signal }); + + expect(post.mock.calls[0][2]).toEqual({ signal }); + }); + + it('passes the signal through channel.queryMembers()', async () => { + const get = vi.spyOn(client, 'get').mockResolvedValue({ members: [] } as never); + const channel = client.channel('messaging', 'channel-1'); + + await channel.queryMembers({}, [], {}, { signal }); + + expect(get.mock.calls[0][2]).toEqual({ signal }); + }); + }); +}); diff --git a/test/unit/search/ChannelMemberSearchSource.test.ts b/test/unit/search/ChannelMemberSearchSource.test.ts index 8cc1e884d..5dfd6bb92 100644 --- a/test/unit/search/ChannelMemberSearchSource.test.ts +++ b/test/unit/search/ChannelMemberSearchSource.test.ts @@ -26,6 +26,11 @@ const getAutocompleteFilters = (searchQuery: string): Partial => $or: [{ name: { $autocomplete: searchQuery } }, { id: { $eq: searchQuery } }], }); +/** Requests dispatched through search() carry the source's abort signal. */ +const withSignal = { signal: expect.any(AbortSignal) }; +/** query() invoked directly in tests is not driven by executeQuery, so it has none. */ +const withoutSignal = {}; + describe('ChannelMemberSearchSource', () => { const mockMembers: ChannelMemberResponse[] = [ createChannelMember({ user_id: 'user-1', user: { id: 'user-1', name: 'Alice' } }), @@ -130,10 +135,15 @@ describe('ChannelMemberSearchSource', () => { expect(searchSource.canExecuteQuery()).toBe(false); }); - it('returns false while loading', () => { + it('lets a new search query preempt an in-flight one', () => { + searchSource.state.partialNext({ isLoading: true }); + + expect(searchSource.canExecuteQuery('')).toBe(true); + }); + + it('returns false for pagination while loading', () => { searchSource.state.partialNext({ isLoading: true }); - expect(searchSource.canExecuteQuery('')).toBe(false); expect(searchSource.canExecuteQuery()).toBe(false); }); @@ -180,6 +190,7 @@ describe('ChannelMemberSearchSource', () => { limit: searchSource.pageSize, offset: searchSource.offset, }, + withoutSignal, ); }); @@ -192,16 +203,18 @@ describe('ChannelMemberSearchSource', () => { }); describe('search', () => { - it('executes empty search queries after debounce', async () => { + it('executes empty search queries after the short-query debounce', async () => { searchSource.search(''); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); expect(searchSource.items).toEqual(mockMembers); expect(searchSource.searchQuery).toBe(''); - expect(channel.queryMembers).toHaveBeenCalledWith({}, [], { - limit: 10, - offset: 0, - }); + expect(channel.queryMembers).toHaveBeenCalledWith( + {}, + [], + { limit: 10, offset: 0 }, + withSignal, + ); }); it('executes typed search queries with autocomplete filters', async () => { @@ -213,6 +226,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('john'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -228,6 +242,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('john'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -243,6 +258,7 @@ describe('ChannelMemberSearchSource', () => { getAutocompleteFilters('second'), [], { limit: 10, offset: 0 }, + withSignal, ); }); @@ -262,18 +278,21 @@ describe('ChannelMemberSearchSource', () => { paginatedSource.activate(); paginatedSource.search(''); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); expect(paginatedSource.items).toEqual(firstPage); expect(paginatedSource.hasNext).toBe(true); paginatedSource.search(); - await vi.advanceTimersByTimeAsync(300); + await vi.advanceTimersByTimeAsync(500); - expect(queryMembersMock).toHaveBeenNthCalledWith(2, {}, [], { - limit: 2, - offset: 2, - }); + expect(queryMembersMock).toHaveBeenNthCalledWith( + 2, + {}, + [], + { limit: 2, offset: 2 }, + withSignal, + ); expect(paginatedSource.items).toEqual([...firstPage, ...secondPage]); expect(paginatedSource.hasNext).toBe(false); }); diff --git a/test/unit/search/ChannelSearchSource.test.ts b/test/unit/search/ChannelSearchSource.test.ts index 1a84c2fa9..48f33d1a9 100644 --- a/test/unit/search/ChannelSearchSource.test.ts +++ b/test/unit/search/ChannelSearchSource.test.ts @@ -154,6 +154,7 @@ describe('ChannelSearchSource', () => { }, { last_message_at: -1 }, { message_limit: 5, limit: searchSource.pageSize, offset: searchSource.offset }, + {}, ); }); diff --git a/test/unit/search/MessageSearchSource.test.ts b/test/unit/search/MessageSearchSource.test.ts index 0246d4864..bfb09384b 100644 --- a/test/unit/search/MessageSearchSource.test.ts +++ b/test/unit/search/MessageSearchSource.test.ts @@ -234,6 +234,7 @@ describe('MessageSearchSource', () => { next: undefined, sort: { created_at: -1 }, }), + {}, ); expect(result.items).toEqual(messages); expect(result.next).toBe('next-token'); @@ -264,6 +265,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -310,6 +312,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: 1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -333,6 +336,7 @@ describe('MessageSearchSource', () => { next: 'next-token-old', sort: { created_at: -1 }, // note: merges created_at with default -1, order may vary }), + {}, ); }); @@ -353,9 +357,27 @@ describe('MessageSearchSource', () => { { cid: { $in: ['cid2'] }, type: 'abc' }, { last_message_at: -1 }, undefined, + {}, ); }); + it('skips the channel hydration request when the query was aborted', async () => { + const m1 = generateMsg({ cid: 'cid1' }); + client.activeChannels = {}; + searchMock.mockResolvedValueOnce({ + results: [{ message: m1 }], + next: undefined, + } as any); + const controller = new AbortController(); + controller.abort(); + + // @ts-expect-error protected access + const result = await searchSource.query('query', { signal: controller.signal }); + + expect(queryChannelsMock).not.toHaveBeenCalled(); + expect(result.items).toEqual([m1]); + }); + it('does not call queryChannels if all channels are loaded locally', async () => { const m1 = generateMsg({ cid: 'cid1' }); client.activeChannels = { cid1: {} as any }; @@ -393,6 +415,7 @@ describe('MessageSearchSource', () => { { cid: { $in: ['cid2'] }, type: 'efg' }, { last_message_at: -1 }, undefined, + {}, ); }); diff --git a/test/unit/search/SearchController.test.js b/test/unit/search/SearchController.test.js index 3a37ff93f..b707fcb7a 100644 --- a/test/unit/search/SearchController.test.js +++ b/test/unit/search/SearchController.test.js @@ -474,7 +474,10 @@ describe('BaseSearchSource and implementations', () => { searchSource.activate(); const querySpy = sinon.spy(searchSource, 'query'); await searchSource.executeQuery(''); - sinon.assert.calledOnceWithExactly(querySpy, ''); + sinon.assert.calledOnce(querySpy); + sinon.assert.calledWith(querySpy, '', { + signal: sinon.match.instanceOf(AbortSignal), + }); expect(searchSource.searchQuery).to.equal(''); expect(searchSource.items).to.be.eql(items); }); diff --git a/test/unit/search/UserSearchSource.test.ts b/test/unit/search/UserSearchSource.test.ts index 17d6721f0..16efd96d5 100644 --- a/test/unit/search/UserSearchSource.test.ts +++ b/test/unit/search/UserSearchSource.test.ts @@ -174,6 +174,7 @@ describe('UserSearchSource', () => { }, { id: 1, created_at: -1 }, { presence: true, limit: searchSource.pageSize, offset: searchSource.offset }, + {}, ); }); @@ -187,6 +188,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ created_at: -1 }, { id: 1 }], expect.anything(), + {}, ); }); @@ -201,6 +203,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ id: -1 }, { created_at: -1 }], expect.anything(), + {}, ); }); @@ -214,6 +217,7 @@ describe('UserSearchSource', () => { expect.anything(), [{ id: 1 }], expect.anything(), + {}, ); }); diff --git a/test/unit/search/searchDebounce.test.ts b/test/unit/search/searchDebounce.test.ts new file mode 100644 index 000000000..89a4c4d36 --- /dev/null +++ b/test/unit/search/searchDebounce.test.ts @@ -0,0 +1,316 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Channel } from '../../../src/channel'; +import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSearchSource'; +import type { ChannelMemberResponse } from '../../../src/types'; + +const SHORT_QUERY_DEBOUNCE_MS = 500; +const LONG_QUERY_DEBOUNCE_MS = 300; + +const createChannelMember = (userId: string): ChannelMemberResponse => ({ + created_at: '2026-01-01T00:00:00.000000000Z', + updated_at: '2026-01-01T00:00:00.000000000Z', + user_id: userId, +}); + +const createChannel = () => ({ queryMembers: vi.fn() }) as unknown as Channel; + +const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +}; + +const membersResponse = (members: ChannelMemberResponse[]) => ({ + duration: '0.01s', + members, +}); + +describe('search source dynamic debounce', () => { + let channel: Channel; + let source: ChannelMemberSearchSource; + + beforeEach(() => { + vi.useFakeTimers(); + channel = createChannel(); + vi.spyOn(channel, 'queryMembers').mockResolvedValue( + membersResponse([createChannelMember('user-1')]), + ); + source = new ChannelMemberSearchSource(channel); + source.activate(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('waits the short-query interval for a query of at most 2 characters', async () => { + source.search('ab'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS - LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('waits the long-query interval for a query of at least 3 characters', async () => { + source.search('abc'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('switches to the long interval once the query grows past the threshold', async () => { + source.search('a'); + await vi.advanceTimersByTimeAsync(100); + source.search('ab'); + await vi.advanceTimersByTimeAsync(100); + source.search('abc'); + + // the last keystroke rescheduled at the long interval, not the short one + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(source.searchQuery).toBe('abc'); + }); + + it('switches back to the short interval when the query shrinks', async () => { + source.search('abc'); + await vi.advanceTimersByTimeAsync(100); + source.search('ab'); + + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS - LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + + it('debounces pagination using the length of the current search query', async () => { + // a full page keeps hasNext true, so pagination is allowed + vi.spyOn(channel, 'queryMembers').mockResolvedValue( + membersResponse( + Array.from({ length: 10 }, (_, index) => createChannelMember(`user-${index}`)), + ), + ); + + source.search('abc'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + source.search(); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + describe('configuration', () => { + it('honours custom debounce intervals and threshold', async () => { + const configured = new ChannelMemberSearchSource(channel, { + shortQueryDebounceMs: 800, + longQueryDebounceMs: 100, + shortQueryMaxLength: 4, + }); + configured.activate(); + + configured.search('abcd'); // still short under the custom threshold + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + configured.search('abcde'); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('applies a legacy debounceMs to both short and long queries', async () => { + const legacy = new ChannelMemberSearchSource(channel, { debounceMs: 800 }); + legacy.activate(); + + legacy.search('ab'); + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + legacy.search('abcdef'); + await vi.advanceTimersByTimeAsync(700); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(100); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('applies intervals updated through setDebounceOptions', async () => { + source.setDebounceOptions({ shortQueryDebounceMs: 900 }); + + source.search('ab'); + await vi.advanceTimersByTimeAsync(SHORT_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(900 - SHORT_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('search source request cancellation', () => { + let channel: Channel; + let source: ChannelMemberSearchSource; + const stalePage = [createChannelMember('stale')]; + const freshPage = [createChannelMember('fresh')]; + + const signalOfCall = (index: number) => + (channel.queryMembers as unknown as ReturnType).mock.calls[index][3] + .signal as AbortSignal; + + beforeEach(() => { + vi.useFakeTimers(); + channel = createChannel(); + source = new ChannelMemberSearchSource(channel); + source.activate(); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it('aborts the in-flight request when a new search is dispatched', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + expect(signalOfCall(0).aborted).toBe(false); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(signalOfCall(0).aborted).toBe(true); + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + }); + + it('discards a late response from an aborted request', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + // the first request only comes back now, after it was superseded + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toEqual(freshPage); + expect(source.searchQuery).toBe('second'); + expect(source.isLoading).toBe(false); + }); + + it('does not surface the abort rejection as a query error', async () => { + // mimics axios rejecting once the signal is aborted + let rejectAborted!: (reason: Error) => void; + const aborting = new Promise((_, reject) => { + rejectAborted = reject; + }); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(aborting as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(signalOfCall(0).aborted).toBe(true); + rejectAborted(new Error('canceled')); + await vi.advanceTimersByTimeAsync(0); + + expect(source.lastQueryError).toBeUndefined(); + expect(source.items).toEqual(freshPage); + }); + + it('aborts the in-flight request on cancelScheduledQuery', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.cancelScheduledQuery(); + expect(signalOfCall(0).aborted).toBe(true); + + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toBeUndefined(); + // nothing dispatches a successor, so cancelling must release the loading state + expect(source.isLoading).toBe(false); + expect(source.canExecuteQuery()).toBe(true); + }); + + it('aborts the in-flight request on resetState', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.resetState(); + expect(signalOfCall(0).aborted).toBe(true); + + inFlight.resolve(membersResponse(stalePage)); + await vi.advanceTimersByTimeAsync(0); + + expect(source.items).toBeUndefined(); + expect(source.searchQuery).toBe(''); + expect(source.isLoading).toBe(false); + }); + + it('lets a new search query preempt an in-flight one', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers') + .mockReturnValueOnce(inFlight.promise as never) + .mockResolvedValueOnce(membersResponse(freshPage)); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + expect(source.isLoading).toBe(true); + + // previously this was dropped because the source was still loading + source.search('second'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(2); + expect(source.items).toEqual(freshPage); + }); + + it('does not start a pagination query while one is in flight', async () => { + const inFlight = deferred>(); + vi.spyOn(channel, 'queryMembers').mockReturnValueOnce(inFlight.promise as never); + + source.search('first'); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + source.search(); + await vi.advanceTimersByTimeAsync(LONG_QUERY_DEBOUNCE_MS); + + expect(channel.queryMembers).toHaveBeenCalledTimes(1); + + inFlight.resolve(membersResponse(freshPage)); + await vi.advanceTimersByTimeAsync(0); + }); +}); diff --git a/test/unit/search/searchSourceConstructorOptions.test.ts b/test/unit/search/searchSourceConstructorOptions.test.ts new file mode 100644 index 000000000..fb5bd3e39 --- /dev/null +++ b/test/unit/search/searchSourceConstructorOptions.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Channel } from '../../../src/channel'; +import { ChannelMemberSearchSource } from '../../../src/search/ChannelMemberSearchSource'; +import { ChannelSearchSource } from '../../../src/search/ChannelSearchSource'; +import { MessageSearchSource } from '../../../src/search/MessageSearchSource'; +import { UserSearchSource } from '../../../src/search/UserSearchSource'; +import { MentionsSearchSource } from '../../../src/messageComposer/middleware/textComposer/mentions'; +import { getClientWithUser } from '../test-utils/getClient'; + +const channelStub = (client: unknown) => + ({ + getClient: () => client, + state: { members: {}, watchers: {} }, + queryMembers: vi.fn(), + }) as unknown as Channel; + +describe('search source query parameters as constructor options', () => { + it('seeds ChannelSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { + filters: { type: 'messaging' }, + searchOptions: { presence: true }, + sort: { last_message_at: -1 }, + }); + + expect(source.filters).toEqual({ type: 'messaging' }); + expect(source.sort).toEqual({ last_message_at: -1 }); + expect(source.searchOptions).toEqual({ presence: true }); + }); + + it('seeds UserSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new UserSearchSource(client, { + filters: { role: { $eq: 'admin' } }, + sort: [{ name: 1 }], + }); + + expect(source.filters).toEqual({ role: { $eq: 'admin' } }); + expect(source.sort).toEqual([{ name: 1 }]); + }); + + it('seeds ChannelMemberSearchSource', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelMemberSearchSource(channelStub(client), { + filters: { user_id: 'user-2' }, + sort: [{ user_id: 1 }], + }); + + expect(source.filters).toEqual({ user_id: 'user-2' }); + expect(source.sort).toEqual([{ user_id: 1 }]); + }); + + it('seeds all six MessageSearchSource parameters', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new MessageSearchSource(client, { + channelQueryFilters: { type: 'team' }, + channelQueryOptions: { presence: false }, + channelQuerySort: { last_message_at: -1 }, + messageSearchChannelFilters: { type: 'messaging' }, + messageSearchFilters: { type: 'regular' }, + messageSearchSort: { created_at: 1 }, + }); + + expect(source.messageSearchChannelFilters).toEqual({ type: 'messaging' }); + expect(source.messageSearchFilters).toEqual({ type: 'regular' }); + expect(source.messageSearchSort).toEqual({ created_at: 1 }); + expect(source.channelQueryFilters).toEqual({ type: 'team' }); + expect(source.channelQuerySort).toEqual({ last_message_at: -1 }); + expect(source.channelQueryOptions).toEqual({ presence: false }); + }); + + it('seeds MentionsSearchSource user and member parameters', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new MentionsSearchSource(channelStub(client), { + memberFilters: { user_id: 'user-2' }, + memberSort: [{ user_id: 1 }], + userFilters: { role: { $eq: 'admin' } }, + userSort: [{ name: 1 }], + }); + + expect(source.userFilters).toEqual({ role: { $eq: 'admin' } }); + expect(source.userSort).toEqual([{ name: 1 }]); + expect(source.memberFilters).toEqual({ user_id: 'user-2' }); + expect(source.memberSort).toEqual([{ user_id: 1 }]); + }); + + it('leaves query parameters undefined when no options are given', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client); + + expect(source.filters).toBeUndefined(); + expect(source.sort).toBeUndefined(); + expect(source.searchOptions).toBeUndefined(); + }); + + it('still honours the SearchSourceOptions passed alongside them', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { + filters: { type: 'messaging' }, + pageSize: 42, + }); + + expect(source.pageSize).toBe(42); + expect(source.filters).toEqual({ type: 'messaging' }); + }); + + it('keeps property assignment working as the way to change them later', () => { + const client = getClientWithUser({ id: 'u1' }); + const source = new ChannelSearchSource(client, { filters: { type: 'messaging' } }); + + source.filters = { type: 'team' }; + + expect(source.filters).toEqual({ type: 'team' }); + }); +}); diff --git a/test/unit/user_groups.test.ts b/test/unit/user_groups.test.ts index ccba4a00e..edd59dda3 100644 --- a/test/unit/user_groups.test.ts +++ b/test/unit/user_groups.test.ts @@ -120,7 +120,11 @@ describe('User Groups', () => { const result = await client.searchUserGroups(options); - expect(getSpy).toHaveBeenCalledWith(`${client.baseURL}/usergroups/search`, options); + expect(getSpy).toHaveBeenCalledWith( + `${client.baseURL}/usergroups/search`, + options, + {}, + ); expect(result.user_groups).toHaveLength(1); expect(result.user_groups[0].name).toBe('Backend Support'); });