5a56d73fb2
## Changes Made - Added phone fields to search indexing: Extended searchable field types to include `FieldMetadataType.PHONES` - Updated person entity search configuration: Added `phones` to the fields indexed for person records - Enhanced search format support: Phone numbers are now indexed in multiple formats: - Raw number: `2071234567` - International with plus: `+442071234567` - International without plus: `442071234567` - Optimized for phone data: Removed unnecessary text processing (e.g. unaccenting) for numeric phone fields - Created workspace migration: New command to regenerate search vectors for existing workspaces ## Technical Details The implementation modifies PostgreSQL `tsvector` generation to index both `primaryPhoneNumber` and `primaryPhoneCallingCode` fields, combining them into international formats. This enables users to search phone numbers using the formats they naturally type. ### Modified Files - `is-searchable-field.util.ts` – Added `PHONES` to searchable types - `person.workspace-entity.ts` – Included `phones` in person search fields - `get-ts-vector-column-expression.util.ts` – Enhanced expression generation to support multiple phone number formats - `is-searchable-subfield.util.ts` – Added subfield filtering logic for phone fields ## Testing - **Unit tests**: Validated `tsvector` expression generation and phone-specific logic - **Integration tests**: Covered phone search scenarios across multiple formats ## Migration Includes the `upgrade:1-7:regenerate-person-search-vector-with-phones` command, which safely updates existing workspaces by dropping and recreating search vectors with phone indexing support. ## Note Frontend and Backend are both storing normalized phone numbers, as they should. The issue turned out to be with the seed file instead, which contained outdated records. I relied on the database as the source of truth without testing via the creation of a new record and it was an incorrect evaluation on my part. Note taken, I will be more comprehensive with my analysis from here on since I now understand I must check comprehensively before reaching a conclusion.
1047 lines
29 KiB
TypeScript
1047 lines
29 KiB
TypeScript
import { COMPANY_GQL_FIELDS } from 'test/integration/constants/company-gql-fields.constants';
|
|
import { OBJECT_MODEL_COMMON_FIELDS } from 'test/integration/constants/object-model-common-fields';
|
|
import { PERSON_GQL_FIELDS } from 'test/integration/constants/person-gql-fields.constants';
|
|
import {
|
|
TEST_COMPANY_1_ID,
|
|
TEST_COMPANY_2_ID,
|
|
} from 'test/integration/constants/test-company-ids.constants';
|
|
import {
|
|
TEST_PERSON_1_ID,
|
|
TEST_PERSON_2_ID,
|
|
TEST_PERSON_3_ID,
|
|
TEST_PERSON_4_ID,
|
|
TEST_PERSON_5_ID,
|
|
TEST_PERSON_6_ID,
|
|
TEST_PERSON_7_ID,
|
|
} from 'test/integration/constants/test-person-ids.constants';
|
|
import {
|
|
TEST_PET_ID_1,
|
|
TEST_PET_ID_2,
|
|
TEST_PET_ID_3,
|
|
TEST_PET_ID_4,
|
|
} from 'test/integration/constants/test-pet-ids.constants';
|
|
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
|
|
import { performCreateManyOperation } from 'test/integration/graphql/utils/perform-create-many-operation.utils';
|
|
import { searchFactory } from 'test/integration/graphql/utils/search-factory.util';
|
|
import { deleteAllRecords } from 'test/integration/utils/delete-all-records';
|
|
import { type EachTestingContext } from 'twenty-shared/testing';
|
|
|
|
import {
|
|
decodeCursor,
|
|
encodeCursorData,
|
|
} from 'src/engine/api/graphql/graphql-query-runner/utils/cursors.util';
|
|
import { type SearchArgs } from 'src/engine/core-modules/search/dtos/search-args';
|
|
import { type SearchResultEdgeDTO } from 'src/engine/core-modules/search/dtos/search-result-edge.dto';
|
|
import { type SearchCursor } from 'src/engine/core-modules/search/services/search.service';
|
|
|
|
describe('SearchResolver', () => {
|
|
const persons = [
|
|
{
|
|
id: TEST_PERSON_1_ID,
|
|
name: { firstName: 'searchInput1' },
|
|
phones: {
|
|
primaryPhoneNumber: '2071234567',
|
|
primaryPhoneCallingCode: '+44',
|
|
primaryPhoneCountryCode: 'GB',
|
|
},
|
|
},
|
|
{
|
|
id: TEST_PERSON_2_ID,
|
|
name: { firstName: 'searchInput2' },
|
|
phones: {
|
|
primaryPhoneNumber: '5551234567',
|
|
primaryPhoneCallingCode: '+1',
|
|
primaryPhoneCountryCode: 'US',
|
|
},
|
|
},
|
|
{ id: TEST_PERSON_3_ID, name: { firstName: 'searchInput3' } },
|
|
{
|
|
id: TEST_PERSON_4_ID,
|
|
name: { firstName: 'José', lastName: 'García' },
|
|
jobTitle: 'Café Manager',
|
|
emails: { primaryEmail: 'josé@café.com' },
|
|
phones: {
|
|
primaryPhoneNumber: '123456789',
|
|
primaryPhoneCallingCode: '+33',
|
|
primaryPhoneCountryCode: 'FR',
|
|
},
|
|
},
|
|
{
|
|
id: TEST_PERSON_5_ID,
|
|
name: { firstName: 'François', lastName: 'Müller' },
|
|
jobTitle: 'Manager',
|
|
emails: { primaryEmail: 'françois@naïve.com' },
|
|
},
|
|
{
|
|
id: TEST_PERSON_6_ID,
|
|
name: { firstName: 'Jose', lastName: 'Garcia' },
|
|
jobTitle: 'Cafe Manager',
|
|
emails: { primaryEmail: 'jose@cafe.com' },
|
|
},
|
|
{
|
|
id: TEST_PERSON_7_ID,
|
|
name: { firstName: 'Francois', lastName: 'Muller' },
|
|
jobTitle: 'Manager',
|
|
emails: { primaryEmail: 'francois@naive.com' },
|
|
},
|
|
];
|
|
|
|
const companies = [
|
|
{ id: TEST_COMPANY_1_ID, name: 'Café Corp' },
|
|
{ id: TEST_COMPANY_2_ID, name: 'Naïve Solutions' },
|
|
];
|
|
|
|
const pets = [
|
|
{ id: TEST_PET_ID_1, name: 'searchInput1' },
|
|
{ id: TEST_PET_ID_2, name: 'searchInput2' },
|
|
{ id: TEST_PET_ID_3, name: 'Café' },
|
|
{ id: TEST_PET_ID_4, name: 'Naïve' },
|
|
];
|
|
|
|
const [
|
|
searchInput1Person,
|
|
searchInput2Person,
|
|
searchInput3Person,
|
|
josePerson,
|
|
francoisPerson,
|
|
josePersonNoAccent,
|
|
francoisPersonNoAccent,
|
|
] = persons;
|
|
const [cafeCorp, naiveCorp] = companies;
|
|
const [searchInput1Pet, searchInput2Pet, cafePet, naivePet] = pets;
|
|
|
|
beforeAll(async () => {
|
|
await deleteAllRecords('person');
|
|
await deleteAllRecords('company');
|
|
await deleteAllRecords('opportunity');
|
|
await deleteAllRecords('note');
|
|
await deleteAllRecords('task');
|
|
await deleteAllRecords('noteTarget');
|
|
await deleteAllRecords('taskTarget');
|
|
await deleteAllRecords('dashboard');
|
|
await deleteAllRecords('_pet');
|
|
await deleteAllRecords('_surveyResult');
|
|
|
|
try {
|
|
await performCreateManyOperation(
|
|
'pet',
|
|
'pets',
|
|
OBJECT_MODEL_COMMON_FIELDS,
|
|
pets,
|
|
);
|
|
|
|
await performCreateManyOperation(
|
|
'person',
|
|
'people',
|
|
PERSON_GQL_FIELDS,
|
|
persons,
|
|
);
|
|
|
|
await performCreateManyOperation(
|
|
'company',
|
|
'companies',
|
|
COMPANY_GQL_FIELDS,
|
|
companies,
|
|
);
|
|
} catch (error) {
|
|
// eslint-disable-next-line no-console
|
|
console.log(error);
|
|
throw new Error('beforeAll failed');
|
|
}
|
|
});
|
|
|
|
const testsUseCases: EachTestingContext<{
|
|
input: SearchArgs;
|
|
eval: {
|
|
orderedRecordIds: string[];
|
|
pageInfo: {
|
|
hasNextPage: boolean;
|
|
decodedEndCursor: SearchCursor | null;
|
|
};
|
|
};
|
|
}>[] = [
|
|
{
|
|
title:
|
|
'should return all records for "isSearchable:true" objects when no search input is provided',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
searchInput1Person.id,
|
|
searchInput2Person.id,
|
|
searchInput3Person.id,
|
|
josePerson.id,
|
|
francoisPerson.id,
|
|
josePersonNoAccent.id,
|
|
francoisPersonNoAccent.id,
|
|
naiveCorp.id,
|
|
cafeCorp.id,
|
|
searchInput1Pet.id,
|
|
searchInput2Pet.id,
|
|
cafePet.id,
|
|
naivePet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
person: francoisPersonNoAccent.id,
|
|
company: cafeCorp.id,
|
|
pet: naivePet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return filtered records when search input is provided',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput1',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id, searchInput1Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return record from included Objects only',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
includedObjectNameSingulars: ['pet'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
searchInput1Pet.id,
|
|
searchInput2Pet.id,
|
|
cafePet.id,
|
|
naivePet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
pet: naivePet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should not return record from excludedObject',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember', 'person'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
naiveCorp.id,
|
|
cafeCorp.id,
|
|
searchInput1Pet.id,
|
|
searchInput2Pet.id,
|
|
cafePet.id,
|
|
naivePet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
company: cafeCorp.id,
|
|
pet: naivePet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return filtered records when filter is provided',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
filter: { id: { eq: searchInput1Pet.id } },
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should limit records number with limit',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 4,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
searchInput1Person.id,
|
|
searchInput2Person.id,
|
|
searchInput3Person.id,
|
|
josePerson.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePerson.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return endCursor when paginating',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 2,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id, searchInput2Person.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return endCursor when paginating with Cursor',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
after: encodeCursorData({
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
limit: 2,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput3Person.id, josePerson.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePerson.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should limit records number with limit and searchInput',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 4,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
searchInput1Person.id,
|
|
searchInput2Person.id,
|
|
searchInput3Person.id,
|
|
searchInput1Pet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
pet: searchInput1Pet.id,
|
|
person: searchInput3Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should return endCursor when paginating with searchInput',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 2,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id, searchInput2Person.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should return endCursor when paginating with searchInput with Cursor',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
after: encodeCursorData({
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
limit: 2,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput3Person.id, searchInput1Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
pet: searchInput1Pet.id,
|
|
person: searchInput3Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should return endCursor when paginating with searchInput with Cursor and filter',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
after: encodeCursorData({
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
limit: 2,
|
|
filter: { id: { neq: searchInput1Pet.id } },
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput3Person.id, searchInput2Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput3Person.id,
|
|
pet: searchInput2Pet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should paginate properly with excludedObject',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember', 'person'],
|
|
limit: 1,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [naiveCorp.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
company: naiveCorp.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should paginate properly with included Objects only',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
includedObjectNameSingulars: ['pet'],
|
|
limit: 1,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0, tsRankCD: 0 },
|
|
lastRecordIdsPerObject: {
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should paginate properly when no records are returned',
|
|
context: {
|
|
input: {
|
|
searchInput: '',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 0,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
decodedEndCursor: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both "José" and "Jose" when searching for "jose" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'jose',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [josePerson.id, josePersonNoAccent.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.12158542, tsRankCD: 0.2 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePersonNoAccent.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both "García" and "Garcia" when searching for "garcia" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'garcia',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [josePerson.id, josePersonNoAccent.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePersonNoAccent.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both accented and non-accented "Café"/"Cafe" records when searching for "cafe" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'cafe',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
josePerson.id,
|
|
josePersonNoAccent.id,
|
|
cafeCorp.id,
|
|
cafePet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePersonNoAccent.id,
|
|
company: cafeCorp.id,
|
|
pet: cafePet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both accented and non-accented "Naïve"/"Naive" records when searching for "naive" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'naive',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [
|
|
francoisPerson.id,
|
|
francoisPersonNoAccent.id,
|
|
naiveCorp.id,
|
|
naivePet.id,
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: francoisPersonNoAccent.id,
|
|
company: naiveCorp.id,
|
|
pet: naivePet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both "Müller" and "Muller" when searching for "muller" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'muller',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [francoisPerson.id, francoisPersonNoAccent.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: francoisPersonNoAccent.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find both "François" and "Francois" when searching for "francois" (bidirectional accent-insensitive)',
|
|
context: {
|
|
input: {
|
|
searchInput: 'francois',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [francoisPerson.id, francoisPersonNoAccent.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.12158542, tsRankCD: 0.2 },
|
|
lastRecordIdsPerObject: {
|
|
person: francoisPersonNoAccent.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by raw phone number',
|
|
context: {
|
|
input: {
|
|
searchInput: '2071234567',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by international phone number with plus',
|
|
context: {
|
|
input: {
|
|
searchInput: '+442071234567',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by international phone number without plus',
|
|
context: {
|
|
input: {
|
|
searchInput: '442071234567',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by trunk prefix phone number (UK)',
|
|
context: {
|
|
input: {
|
|
searchInput: '02071234567',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by trunk prefix phone number (France)',
|
|
context: {
|
|
input: {
|
|
searchInput: '0123456789',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [josePerson.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePerson.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by US phone number (raw national)',
|
|
context: {
|
|
input: {
|
|
searchInput: '5551234567',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput2Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title: 'should find person by partial phone number',
|
|
context: {
|
|
input: {
|
|
searchInput: '555123',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput2Person.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should find multiple persons when phone search matches multiple records',
|
|
context: {
|
|
input: {
|
|
searchInput: '123456789',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [josePerson.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: josePerson.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
title:
|
|
'should rank phone search results appropriately vs other field matches',
|
|
context: {
|
|
input: {
|
|
searchInput: 'searchInput1',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 50,
|
|
},
|
|
eval: {
|
|
orderedRecordIds: [searchInput1Person.id, searchInput1Pet.id],
|
|
pageInfo: {
|
|
hasNextPage: false,
|
|
decodedEndCursor: {
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
];
|
|
|
|
it.each(testsUseCases)('$title', async ({ context }) => {
|
|
const graphqlOperation = searchFactory(context.input);
|
|
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
|
|
|
expect(response.body.data).toBeDefined();
|
|
expect(response.body.data.search).toBeDefined();
|
|
|
|
const search = response.body.data.search;
|
|
const edges = search.edges;
|
|
const pageInfo = search.pageInfo;
|
|
|
|
if (context.eval.orderedRecordIds.length > 0) {
|
|
expect(edges).not.toHaveLength(0);
|
|
} else {
|
|
expect(edges).toHaveLength(0);
|
|
}
|
|
|
|
expect(
|
|
edges.map((edge: SearchResultEdgeDTO) => edge.node.recordId),
|
|
).toEqual(context.eval.orderedRecordIds);
|
|
|
|
expect(pageInfo).toBeDefined();
|
|
expect(context.eval.pageInfo.hasNextPage).toEqual(pageInfo.hasNextPage);
|
|
expect(context.eval.pageInfo.decodedEndCursor).toEqual(
|
|
pageInfo.endCursor
|
|
? decodeCursor(pageInfo.endCursor)
|
|
: pageInfo.endCursor,
|
|
);
|
|
});
|
|
|
|
it('should return cursor for each search edge', async () => {
|
|
const graphqlOperation = searchFactory({
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 2,
|
|
});
|
|
|
|
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
|
|
|
const expectedResult = {
|
|
edges: [
|
|
{
|
|
cursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput1Person.id,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
cursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
endCursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
|
|
expect({
|
|
...response.body.data.search,
|
|
edges: response.body.data.search.edges.map(
|
|
(edge: SearchResultEdgeDTO) => ({
|
|
cursor: edge.cursor,
|
|
}),
|
|
),
|
|
}).toEqual(expectedResult);
|
|
});
|
|
|
|
it('should return cursor for each search edge with after cursor input', async () => {
|
|
const graphqlOperation = searchFactory({
|
|
searchInput: 'searchInput',
|
|
excludedObjectNameSingulars: ['workspaceMember'],
|
|
limit: 2,
|
|
after: encodeCursorData({
|
|
lastRanks: { tsRank: 0.06079271, tsRankCD: 0.1 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput2Person.id,
|
|
},
|
|
}),
|
|
});
|
|
|
|
const response = await makeGraphqlAPIRequest(graphqlOperation);
|
|
|
|
const expectedResult = {
|
|
edges: [
|
|
{
|
|
cursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput3Person.id,
|
|
},
|
|
}),
|
|
},
|
|
{
|
|
cursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput3Person.id,
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
}),
|
|
},
|
|
],
|
|
pageInfo: {
|
|
hasNextPage: true,
|
|
endCursor: encodeCursorData({
|
|
lastRanks: { tsRankCD: 0.1, tsRank: 0.06079271 },
|
|
lastRecordIdsPerObject: {
|
|
person: searchInput3Person.id,
|
|
pet: searchInput1Pet.id,
|
|
},
|
|
}),
|
|
},
|
|
};
|
|
|
|
expect({
|
|
...response.body.data.search,
|
|
edges: response.body.data.search.edges.map(
|
|
(edge: SearchResultEdgeDTO) => ({
|
|
cursor: edge.cursor,
|
|
}),
|
|
),
|
|
}).toEqual(expectedResult);
|
|
});
|
|
});
|