diff --git a/packages/twenty-front/src/utils/sort.ts b/packages/twenty-front/src/utils/sort.ts index ef9055a8b2d..c09f245f2aa 100644 --- a/packages/twenty-front/src/utils/sort.ts +++ b/packages/twenty-front/src/utils/sort.ts @@ -1,3 +1,5 @@ +import { isNonEmptyString } from '@sniptt/guards'; + import { type Maybe } from '~/generated/graphql'; export const sortNullsFirst = ( @@ -20,7 +22,16 @@ export const sortNullsLast = ( export const sortAsc = ( fieldValueA: string | number, fieldValueB: string | number, -) => (fieldValueA === fieldValueB ? 0 : fieldValueA < fieldValueB ? -1 : 1); +) => { + const valueA = isNonEmptyString(fieldValueA) + ? fieldValueA.toLowerCase() + : fieldValueA; + const valueB = isNonEmptyString(fieldValueB) + ? fieldValueB.toLowerCase() + : fieldValueB; + + return valueA === valueB ? 0 : valueA < valueB ? -1 : 1; +}; export const sortDesc = ( fieldValueA: string | number, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts new file mode 100644 index 00000000000..8b06a88639b --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/build-order-by-key.spec.ts @@ -0,0 +1,87 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; + +describe('buildOrderByColumnExpression', () => { + describe('returns unquoted column expressions for TypeORM', () => { + it('should return unquoted alias.column format', () => { + const result = buildOrderByColumnExpression('company', 'name'); + + expect(result).toBe('company.name'); + }); + + it('should work with different prefixes', () => { + const result = buildOrderByColumnExpression('assignee', 'email'); + + expect(result).toBe('assignee.email'); + }); + + it('should handle composite column names (e.g., nameFirstName)', () => { + const result = buildOrderByColumnExpression('person', 'nameFirstName'); + + expect(result).toBe('person.nameFirstName'); + }); + }); +}); + +describe('shouldUseCaseInsensitiveOrder', () => { + it('should return true for TEXT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.TEXT)).toBe(true); + }); + + it('should return true for SELECT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.SELECT)).toBe(true); + }); + + it('should return true for MULTI_SELECT fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.MULTI_SELECT)).toBe( + true, + ); + }); + + it('should return false for NUMBER fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.NUMBER)).toBe(false); + }); + + it('should return false for DATE_TIME fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.DATE_TIME)).toBe( + false, + ); + }); + + it('should return false for UUID fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.UUID)).toBe(false); + }); + + it('should return false for BOOLEAN fields', () => { + expect(shouldUseCaseInsensitiveOrder(FieldMetadataType.BOOLEAN)).toBe( + false, + ); + }); +}); + +describe('shouldCastToText', () => { + it('should return true for SELECT fields', () => { + expect(shouldCastToText(FieldMetadataType.SELECT)).toBe(true); + }); + + it('should return true for MULTI_SELECT fields', () => { + expect(shouldCastToText(FieldMetadataType.MULTI_SELECT)).toBe(true); + }); + + it('should return false for TEXT fields', () => { + expect(shouldCastToText(FieldMetadataType.TEXT)).toBe(false); + }); + + it('should return false for NUMBER fields', () => { + expect(shouldCastToText(FieldMetadataType.NUMBER)).toBe(false); + }); + + it('should return false for DATE_TIME fields', () => { + expect(shouldCastToText(FieldMetadataType.DATE_TIME)).toBe(false); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts new file mode 100644 index 00000000000..cc763598fcd --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/__tests__/parse-composite-field-for-order.util.spec.ts @@ -0,0 +1,184 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; + +describe('parseCompositeFieldForOrder', () => { + describe('case-insensitive sorting for composite subfields', () => { + it('should set useLower: true for TEXT subfields in FULL_NAME composite', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'AscNullsFirst' }, + 'person', + true, + ); + + expect(result).toEqual({ + 'person.nameFirstName': { + order: 'ASC', + nulls: 'NULLS FIRST', + useLower: true, + castToText: false, + }, + }); + }); + + it('should set useLower: true for TEXT subfields in LINKS composite', () => { + const fieldMetadata = { + type: FieldMetadataType.LINKS, + name: 'linkedinLink', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { primaryLinkLabel: 'DescNullsLast' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.linkedinLinkPrimaryLinkLabel': { + order: 'DESC', + nulls: 'NULLS LAST', + useLower: true, + castToText: false, + }, + }); + }); + + it('should set useLower: true for TEXT subfields in ADDRESS composite', () => { + const fieldMetadata = { + type: FieldMetadataType.ADDRESS, + name: 'address', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { addressCity: 'AscNullsLast' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.addressAddressCity': { + order: 'ASC', + nulls: 'NULLS LAST', + useLower: true, + castToText: false, + }, + }); + }); + }); + + describe('case-sensitive sorting for non-text composite subfields', () => { + it('should set useLower: false for non-TEXT subfields in CURRENCY composite', () => { + const fieldMetadata = { + type: FieldMetadataType.CURRENCY, + name: 'annualRevenue', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { amountMicros: 'DescNullsFirst' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.annualRevenueAmountMicros': { + order: 'DESC', + nulls: 'NULLS FIRST', + useLower: false, + castToText: false, + }, + }); + }); + + it('should set useLower: false for non-TEXT subfields in ADDRESS lat/lng', () => { + const fieldMetadata = { + type: FieldMetadataType.ADDRESS, + name: 'address', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { addressLat: 'AscNullsFirst' }, + 'company', + true, + ); + + expect(result).toEqual({ + 'company.addressAddressLat': { + order: 'ASC', + nulls: 'NULLS FIRST', + useLower: false, + castToText: false, + }, + }); + }); + }); + + describe('pagination direction handling', () => { + it('should reverse order direction for backward pagination', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + const result = parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'AscNullsFirst' }, + 'person', + false, + ); + + expect(result).toEqual({ + 'person.nameFirstName': { + order: 'DESC', + nulls: 'NULLS FIRST', + useLower: true, + castToText: false, + }, + }); + }); + }); + + describe('error handling', () => { + it('should throw error for invalid subfield name', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + expect(() => + parseCompositeFieldForOrder( + fieldMetadata, + { invalidSubField: 'AscNullsFirst' }, + 'person', + true, + ), + ).toThrow('Sub field metadata not found'); + }); + + it('should throw error for invalid order direction', () => { + const fieldMetadata = { + type: FieldMetadataType.FULL_NAME, + name: 'name', + } as FlatFieldMetadata; + + expect(() => + parseCompositeFieldForOrder( + fieldMetadata, + { firstName: 'InvalidDirection' }, + 'person', + true, + ), + ).toThrow('Sub field order by value must be of type OrderByDirection'); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts index bbeb1bea338..1520798949d 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser.ts @@ -8,8 +8,12 @@ import { GraphqlQueryRunnerException, GraphqlQueryRunnerExceptionCode, } from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception'; +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; -import { getOptionalOrderByCasting } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/get-optional-order-by-casting.util'; import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util'; import { parseCompositeFieldForOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; @@ -130,14 +134,19 @@ export class GraphqlQueryOrderFieldParser { ); } - const orderByCasting = getOptionalOrderByCasting(fieldMetadata); - - orderByConditions[ - `${objectNameSingular}.${fieldName}${orderByCasting}` - ] = convertOrderByToFindOptionsOrder( - orderByDirection, - isForwardPagination, + const columnExpression = buildOrderByColumnExpression( + objectNameSingular, + fieldName, ); + + orderByConditions[columnExpression] = { + ...convertOrderByToFindOptionsOrder( + orderByDirection, + isForwardPagination, + ), + useLower: shouldUseCaseInsensitiveOrder(fieldMetadata.type), + castToText: shouldCastToText(fieldMetadata.type), + }; } } } @@ -236,16 +245,21 @@ export class GraphqlQueryOrderFieldParser { } if (isOrderByDirection(nestedFieldOrderByValue)) { - const nestedColumnName = nestedFieldMetadata.name; - const orderByCasting = getOptionalOrderByCasting(nestedFieldMetadata); + const columnExpression = buildOrderByColumnExpression( + joinAlias, + nestedFieldMetadata.name, + ); return { orderBy: { - [`${joinAlias}.${nestedColumnName}${orderByCasting}`]: - convertOrderByToFindOptionsOrder( + [columnExpression]: { + ...convertOrderByToFindOptionsOrder( nestedFieldOrderByValue, isForwardPagination, ), + useLower: shouldUseCaseInsensitiveOrder(nestedFieldMetadata.type), + castToText: shouldCastToText(nestedFieldMetadata.type), + }, }, joinInfo, }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts index 70c0431ec98..36169bd8f4e 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/types/order-by-condition.type.ts @@ -1,4 +1,6 @@ export type OrderByClause = { order: 'ASC' | 'DESC'; nulls?: 'NULLS FIRST' | 'NULLS LAST'; + useLower?: boolean; + castToText?: boolean; // For SELECT/MULTI_SELECT fields that need ::text before LOWER }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts new file mode 100644 index 00000000000..264347d4be2 --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util.ts @@ -0,0 +1,27 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export const shouldUseCaseInsensitiveOrder = ( + fieldType: FieldMetadataType, +): boolean => { + return ( + fieldType === FieldMetadataType.TEXT || + fieldType === FieldMetadataType.SELECT || + fieldType === FieldMetadataType.MULTI_SELECT + ); +}; + +export const shouldCastToText = (fieldType: FieldMetadataType): boolean => { + return ( + fieldType === FieldMetadataType.SELECT || + fieldType === FieldMetadataType.MULTI_SELECT + ); +}; + +// Returns unquoted column expression for TypeORM's orderBy (e.g., "company.name") +// Quoting and LOWER() wrapping is handled in getOrderByRawSQL +export const buildOrderByColumnExpression = ( + prefix: string, + columnName: string, +): string => { + return `${prefix}.${columnName}`; +}; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts index aada0cedbb5..9eda397c539 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/parse-composite-field-for-order.util.ts @@ -2,6 +2,11 @@ import { compositeTypeDefinitions } from 'twenty-shared/types'; import { capitalize } from 'twenty-shared/utils'; import { type OrderByClause } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser'; +import { + buildOrderByColumnExpression, + shouldCastToText, + shouldUseCaseInsensitiveOrder, +} from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/build-order-by-column-expression.util'; import { convertOrderByToFindOptionsOrder } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/convert-order-by-to-find-options-order'; import { isOrderByDirection } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/utils/is-order-by-direction.util'; import { type CompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/types/composite-field-metadata-type.type'; @@ -35,17 +40,22 @@ export const parseCompositeFieldForOrder = ( ); } - const fullFieldName = `${prefix}.${fieldMetadata.name}${capitalize(subFieldKey)}`; + const orderByKey = buildOrderByColumnExpression( + prefix, + `${fieldMetadata.name}${capitalize(subFieldKey)}`, + ); if (!isOrderByDirection(subFieldValue)) { throw new Error( `Sub field order by value must be of type OrderByDirection, but got: ${subFieldValue}`, ); } - acc[fullFieldName] = convertOrderByToFindOptionsOrder( - subFieldValue, - isForwardPagination, - ); + + acc[orderByKey] = { + ...convertOrderByToFindOptionsOrder(subFieldValue, isForwardPagination), + useLower: shouldUseCaseInsensitiveOrder(subFieldMetadata.type), + castToText: shouldCastToText(subFieldMetadata.type), + }; return acc; }, diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts index d5332b5be5f..3b75510a97f 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query.parser.ts @@ -179,7 +179,24 @@ export class GraphqlQueryParser { ? ` ${orderByCondition.nulls}` : ''; - return `${orderByField} ${orderByCondition.order}${nullsCondition}`; + // Convert "alias.column" to quoted SQL identifier "alias"."column" + const parts = orderByField.split('.'); + const quotedColumn = + parts.length === 2 + ? `"${parts[0]}"."${parts[1]}"` + : `"${orderByField}"`; + + // Build column expression with optional ::text cast and LOWER() + let columnExpr = quotedColumn; + + if (orderByCondition.castToText) { + columnExpr = `${columnExpr}::text`; + } + if (orderByCondition.useLower) { + columnExpr = `LOWER(${columnExpr})`; + } + + return `${columnExpr} ${orderByCondition.order}${nullsCondition}`; }, ); diff --git a/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts index 26060a338ed..801b07c837a 100644 --- a/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/order-by-relation-field.integration-spec.ts @@ -6,6 +6,10 @@ const TEST_COMPANY_IDS = { ALPHA: '20202020-aaaa-4000-8000-000000000001', BETA: '20202020-aaaa-4000-8000-000000000002', GAMMA: '20202020-aaaa-4000-8000-000000000003', + // Companies for case-insensitive sorting tests + ACME_LOWER: '20202020-aaaa-4000-8000-000000000004', + ACME_UPPER: '20202020-aaaa-4000-8000-000000000005', + ZEBRA: '20202020-aaaa-4000-8000-000000000006', }; const TEST_PERSON_IDS = [ @@ -21,6 +25,12 @@ const TEST_PERSON_IDS = [ '20202020-bbbb-4000-8000-000000000010', ]; +const CASE_INSENSITIVE_TEST_PERSON_IDS = [ + '20202020-bbbb-4000-8000-000000000011', + '20202020-bbbb-4000-8000-000000000012', + '20202020-bbbb-4000-8000-000000000013', +]; + describe('Order by relation field (e2e)', () => { beforeAll(async () => { // Create test companies with distinct names for sorting verification @@ -32,6 +42,10 @@ describe('Order by relation field (e2e)', () => { { id: TEST_COMPANY_IDS.ALPHA, name: 'Alpha Corp' }, { id: TEST_COMPANY_IDS.BETA, name: 'Beta Inc' }, { id: TEST_COMPANY_IDS.GAMMA, name: 'Gamma LLC' }, + // Companies for case-insensitive sorting tests (lowercase vs uppercase) + { id: TEST_COMPANY_IDS.ACME_LOWER, name: 'acme' }, + { id: TEST_COMPANY_IDS.ACME_UPPER, name: 'ACME' }, + { id: TEST_COMPANY_IDS.ZEBRA, name: 'Zebra' }, ], upsert: true, }); @@ -56,6 +70,19 @@ describe('Order by relation field (e2e)', () => { { id: TEST_PERSON_IDS[7], companyId: null }, { id: TEST_PERSON_IDS[8], companyId: null }, { id: TEST_PERSON_IDS[9], companyId: null }, + // People for case-insensitive sorting tests + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[0], + companyId: TEST_COMPANY_IDS.ACME_LOWER, + }, + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[1], + companyId: TEST_COMPANY_IDS.ACME_UPPER, + }, + { + id: CASE_INSENSITIVE_TEST_PERSON_IDS[2], + companyId: TEST_COMPANY_IDS.ZEBRA, + }, ], upsert: true, }); @@ -425,4 +452,112 @@ describe('Order by relation field (e2e)', () => { expect(Array.isArray(edges)).toBe(true); expect(edges.length).toBeGreaterThan(0); }); + + it('should sort case-insensitively (acme and ACME should sort together before Zebra)', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'AscNullsLast' } }], + filter: { id: { in: CASE_INSENSITIVE_TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + const companyNames = edges.map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ); + + expect(companyNames.length).toBe(3); + + // Both "acme" and "ACME" should come before "Zebra" in case-insensitive sort + const zebraIndex = companyNames.findIndex( + (name: string) => name.toLowerCase() === 'zebra', + ); + const acmeIndices = companyNames + .map((name: string, index: number) => + name.toLowerCase() === 'acme' ? index : -1, + ) + .filter((index: number) => index !== -1); + + // All ACME variants should appear before Zebra + for (const acmeIndex of acmeIndices) { + expect(acmeIndex).toBeLessThan(zebraIndex); + } + }); + + it('should sort case-insensitively in descending order', async () => { + const queryData = { + query: gql` + query People( + $orderBy: [PersonOrderByInput] + $filter: PersonFilterInput + ) { + people(orderBy: $orderBy, filter: $filter, first: 10) { + edges { + node { + id + company { + name + } + } + } + } + } + `, + variables: { + orderBy: [{ company: { name: 'DescNullsLast' } }], + filter: { id: { in: CASE_INSENSITIVE_TEST_PERSON_IDS } }, + }, + }; + + const response = await makeGraphqlAPIRequest(queryData); + + expect(response.body.data).toBeDefined(); + expect(response.body.errors).toBeUndefined(); + + const edges = response.body.data.people.edges; + const companyNames = edges.map( + (edge: { node: { company?: { name: string } } }) => + edge.node.company?.name, + ); + + expect(companyNames.length).toBe(3); + + // In descending case-insensitive order, "Zebra" should come first + const zebraIndex = companyNames.findIndex( + (name: string) => name.toLowerCase() === 'zebra', + ); + const acmeIndices = companyNames + .map((name: string, index: number) => + name.toLowerCase() === 'acme' ? index : -1, + ) + .filter((index: number) => index !== -1); + + // Zebra should appear before all ACME variants in descending order + for (const acmeIndex of acmeIndices) { + expect(zebraIndex).toBeLessThan(acmeIndex); + } + }); });