signal-desktop/ts/test-both/state/selectors/search_test.ts
2023-08-21 09:30:33 -07:00

456 lines
12 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import sinon from 'sinon';
import type {
ConversationType,
MessageType,
} from '../../../state/ducks/conversations';
import { getEmptyState as getEmptyConversationState } from '../../../state/ducks/conversations';
import { noopAction } from '../../../state/ducks/noop';
import type { MessageSearchResultType } from '../../../state/ducks/search';
import { getEmptyState as getEmptySearchState } from '../../../state/ducks/search';
import { getEmptyState as getEmptyUserState } from '../../../state/ducks/user';
import {
getIsSearching,
getIsSearchingGlobally,
getIsSearchingInAConversation,
getMessageSearchResultSelector,
getSearchResults,
} from '../../../state/selectors/search';
import { makeLookup } from '../../../util/makeLookup';
import { generateAci } from '../../../types/ServiceId';
import {
getDefaultConversation,
getDefaultConversationWithServiceId,
} from '../../helpers/getDefaultConversation';
import { ReadStatus } from '../../../messages/MessageReadStatus';
import type { StateType } from '../../../state/reducer';
import { reducer as rootReducer } from '../../../state/reducer';
describe('both/state/selectors/search', () => {
const NOW = 1_000_000;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let clock: any;
beforeEach(() => {
clock = sinon.useFakeTimers({
now: NOW,
});
});
afterEach(() => {
clock.restore();
});
const getEmptyRootState = (): StateType => {
return rootReducer(undefined, noopAction());
};
function getDefaultMessage(id: string): MessageType {
return {
attachments: [],
conversationId: 'conversationId',
id,
received_at: NOW,
sent_at: NOW,
source: 'source',
sourceServiceId: generateAci(),
timestamp: NOW,
type: 'incoming' as const,
readStatus: ReadStatus.Read,
};
}
function getDefaultSearchMessage(id: string): MessageSearchResultType {
return {
...getDefaultMessage(id),
body: 'foo bar',
bodyRanges: [],
snippet: 'foo bar',
};
}
describe('#getIsSearchingInAConversation', () => {
it('returns false if not searching in a conversation', () => {
const state = getEmptyRootState();
assert.isFalse(getIsSearchingInAConversation(state));
});
it('returns true if searching in a conversation', () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
searchConversationId: 'abc123',
searchConversationName: 'Test Conversation',
},
};
assert.isTrue(getIsSearchingInAConversation(state));
});
});
describe('#getIsSearchingGlobally', () => {
it('returns false if not searching', () => {
const state = getEmptyRootState();
assert.isFalse(getIsSearchingGlobally(state));
});
it('returns true if searching globally', () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
globalSearch: true,
},
};
assert.isTrue(getIsSearchingGlobally(state));
});
});
describe('#getIsSearching', () => {
it('returns false if not searching in any manner', () => {
const state = getEmptyRootState();
assert.isFalse(getIsSearching(state));
});
it('returns true if searching in a conversation', () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
searchConversationId: 'abc123',
searchConversationName: 'Test Conversation',
globalSearch: false,
},
};
assert.isTrue(getIsSearching(state));
});
it('returns true if searching globally', () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
searchConversationId: undefined,
globalSearch: true,
},
};
assert.isTrue(getIsSearchingGlobally(state));
});
});
describe('#getMessageSearchResultSelector', () => {
it('returns undefined if message not found in lookup', () => {
const state = getEmptyRootState();
const selector = getMessageSearchResultSelector(state);
const actual = selector('random-id');
assert.strictEqual(actual, undefined);
});
it('returns undefined if type is unexpected', () => {
const id = 'message-id';
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
messageLookup: {
[id]: {
...getDefaultMessage(id),
type: 'keychange' as const,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
},
},
},
};
const selector = getMessageSearchResultSelector(state);
const actual = selector(id);
assert.strictEqual(actual, undefined);
});
it('returns incoming message', () => {
const searchId = 'search-id';
const toId = 'to-id';
const from = getDefaultConversationWithServiceId();
const to = getDefaultConversation({ id: toId });
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyConversationState(),
conversationLookup: {
[from.id]: from,
[toId]: to,
},
conversationsByServiceId: {
[from.serviceId]: from,
},
},
search: {
...getEmptySearchState(),
messageLookup: {
[searchId]: {
...getDefaultMessage(searchId),
type: 'incoming' as const,
sourceServiceId: from.serviceId,
conversationId: toId,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
},
},
},
};
const selector = getMessageSearchResultSelector(state);
const actual = selector(searchId);
const expected = {
from,
to,
id: searchId,
conversationId: toId,
sentAt: NOW,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
isSelected: false,
isSearchingInConversation: false,
};
assert.deepEqual(actual, expected);
});
it('returns the correct "from" and "to" when sent to me', () => {
const searchId = 'search-id';
const myId = 'my-id';
const from = getDefaultConversationWithServiceId();
const toId = from.serviceId;
const meAsRecipient = getDefaultConversation({ id: myId });
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyConversationState(),
conversationLookup: {
[from.id]: from,
[myId]: meAsRecipient,
},
conversationsByServiceId: {
[from.serviceId]: from,
},
},
ourConversationId: myId,
search: {
...getEmptySearchState(),
messageLookup: {
[searchId]: {
...getDefaultMessage(searchId),
type: 'incoming' as const,
sourceServiceId: from.serviceId,
conversationId: toId,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
},
},
},
user: {
...getEmptyUserState(),
ourConversationId: myId,
},
};
const selector = getMessageSearchResultSelector(state);
const actual = selector(searchId);
assert.deepEqual(actual?.from, from);
assert.deepEqual(actual?.to, meAsRecipient);
});
it('returns outgoing message and caches appropriately', () => {
const searchId = 'search-id';
const toId = 'to-id';
const from = getDefaultConversationWithServiceId();
const to = getDefaultConversation({ id: toId });
const state = {
...getEmptyRootState(),
user: {
...getEmptyUserState(),
ourConversationId: from.id,
},
conversations: {
...getEmptyConversationState(),
conversationLookup: {
[from.id]: from,
[toId]: to,
},
conversationsByServiceId: {
[from.serviceId]: from,
},
},
search: {
...getEmptySearchState(),
messageLookup: {
[searchId]: {
...getDefaultMessage(searchId),
type: 'outgoing' as const,
conversationId: toId,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
},
},
},
};
const selector = getMessageSearchResultSelector(state);
const actual = selector(searchId);
const expected = {
from,
to,
id: searchId,
conversationId: toId,
sentAt: NOW,
snippet: 'snippet',
body: 'snippet',
bodyRanges: [],
isSelected: false,
isSearchingInConversation: false,
};
assert.deepEqual(actual, expected);
// Update the conversation lookup, but not the conversations in question
const secondState = {
...state,
conversations: {
...state.conversations,
},
};
const secondSelector = getMessageSearchResultSelector(secondState);
const secondActual = secondSelector(searchId);
assert.strictEqual(secondActual, actual);
// Update a conversation involved in rendering this search result
const thirdState = {
...state,
conversations: {
...state.conversations,
conversationsByServiceId: {
...state.conversations.conversationsByServiceId,
[from.serviceId]: {
...from,
name: 'new-name',
},
},
},
};
const thirdSelector = getMessageSearchResultSelector(thirdState);
const thirdActual = thirdSelector(searchId);
assert.notStrictEqual(actual, thirdActual);
});
});
describe('#getSearchResults', () => {
it("returns loading search results when they're loading", () => {
const state = {
...getEmptyRootState(),
search: {
...getEmptySearchState(),
query: 'foo bar',
discussionsLoading: true,
messagesLoading: true,
},
};
assert.deepEqual(getSearchResults(state), {
conversationResults: { isLoading: true },
contactResults: { isLoading: true },
messageResults: { isLoading: true },
searchConversationName: undefined,
searchTerm: 'foo bar',
});
});
it('returns loaded search results', () => {
const conversations: Array<ConversationType> = [
getDefaultConversation({ id: '1' }),
getDefaultConversation({ id: '2' }),
];
const contacts: Array<ConversationType> = [
getDefaultConversation({ id: '3' }),
getDefaultConversation({ id: '4' }),
getDefaultConversation({ id: '5' }),
];
const messages: Array<MessageSearchResultType> = [
getDefaultSearchMessage('a'),
getDefaultSearchMessage('b'),
getDefaultSearchMessage('c'),
];
const getId = ({ id }: Readonly<{ id: string }>) => id;
const state: StateType = {
...getEmptyRootState(),
conversations: {
// This test state is invalid, but is good enough for this test.
...getEmptyConversationState(),
conversationLookup: makeLookup([...conversations, ...contacts], 'id'),
},
search: {
...getEmptySearchState(),
query: 'foo bar',
conversationIds: conversations.map(getId),
contactIds: contacts.map(getId),
messageIds: messages.map(getId),
messageLookup: makeLookup(messages, 'id'),
discussionsLoading: false,
messagesLoading: false,
},
};
assert.deepEqual(getSearchResults(state), {
conversationResults: {
isLoading: false,
results: conversations,
},
contactResults: {
isLoading: false,
results: contacts,
},
messageResults: {
isLoading: false,
results: messages,
},
searchConversationName: undefined,
searchTerm: 'foo bar',
});
});
});
});