Revert "[4/n]: migrate the RESTAPI GET /rest/* to use TwentyORM direc… (#11344)

…tly (#10372)"

This reverts commit a26b3f54d6.
This commit is contained in:
martmull
2025-04-02 13:39:28 +02:00
committed by GitHub
parent e47c19e86f
commit c6afb0d1ba
12 changed files with 50 additions and 725 deletions

View File

@ -37,7 +37,6 @@ const MIGRATED_REST_METHODS = [
RequestMethod.POST,
RequestMethod.PATCH,
RequestMethod.PUT,
RequestMethod.GET,
];
@Module({

View File

@ -6,6 +6,7 @@ import {
} from 'typeorm';
import { ObjectRecordFilter } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
@ -15,10 +16,14 @@ export class GraphqlQueryFilterConditionParser {
private fieldMetadataMapByName: FieldMetadataMap;
private queryFilterFieldParser: GraphqlQueryFilterFieldParser;
constructor(fieldMetadataMapByName: FieldMetadataMap) {
constructor(
fieldMetadataMapByName: FieldMetadataMap,
featureFlagsMap: FeatureFlagMap,
) {
this.fieldMetadataMapByName = fieldMetadataMapByName;
this.queryFilterFieldParser = new GraphqlQueryFilterFieldParser(
this.fieldMetadataMapByName,
featureFlagsMap,
);
}

View File

@ -1,6 +1,7 @@
import { WhereExpressionBuilder } from 'typeorm';
import { capitalize } from 'twenty-shared/utils';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import {
@ -17,9 +18,14 @@ const ARRAY_OPERATORS = ['in', 'contains', 'notContains'];
export class GraphqlQueryFilterFieldParser {
private fieldMetadataMapByName: FieldMetadataMap;
private featureFlagsMap: FeatureFlagMap;
constructor(fieldMetadataMapByName: FieldMetadataMap) {
constructor(
fieldMetadataMapByName: FieldMetadataMap,
featureFlagsMap: FeatureFlagMap,
) {
this.fieldMetadataMapByName = fieldMetadataMapByName;
this.featureFlagsMap = featureFlagsMap;
}
public parse(

View File

@ -4,6 +4,7 @@ import {
ObjectRecordOrderBy,
OrderByDirection,
} from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface';
import {
@ -17,9 +18,14 @@ import { CompositeFieldMetadataType } from 'src/engine/metadata-modules/workspac
export class GraphqlQueryOrderFieldParser {
private fieldMetadataMapByName: FieldMetadataMap;
private featureFlagsMap: FeatureFlagMap;
constructor(fieldMetadataMapByName: FieldMetadataMap) {
constructor(
fieldMetadataMapByName: FieldMetadataMap,
featureFlagsMap: FeatureFlagMap,
) {
this.fieldMetadataMapByName = fieldMetadataMapByName;
this.featureFlagsMap = featureFlagsMap;
}
parse(

View File

@ -39,9 +39,11 @@ export class GraphqlQueryParser {
this.featureFlagsMap = featureFlagsMap;
this.filterConditionParser = new GraphqlQueryFilterConditionParser(
this.fieldMetadataMapByName,
featureFlagsMap,
);
this.orderFieldParser = new GraphqlQueryOrderFieldParser(
this.fieldMetadataMapByName,
featureFlagsMap,
);
}

View File

@ -22,7 +22,6 @@ import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
@Controller('rest/*')
@UseGuards(JwtAuthGuard, WorkspaceAuthGuard)
@UseFilters(RestApiExceptionFilter)
export class RestApiCoreController {
constructor(
private readonly restApiCoreService: RestApiCoreService,
@ -30,6 +29,7 @@ export class RestApiCoreController {
) {}
@Post('/duplicates')
@UseFilters(RestApiExceptionFilter)
async handleApiFindDuplicates(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreService.findDuplicates(request);
@ -37,13 +37,17 @@ export class RestApiCoreController {
}
@Get()
@UseFilters(RestApiExceptionFilter)
async handleApiGet(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.get(request);
const result = await this.restApiCoreService.get(request);
res.status(200).send(result);
res.status(200).send(cleanGraphQLResponse(result.data.data));
}
@Delete()
// We should move this exception filter to RestApiCoreController class level
// when all endpoints are migrated to v2
@UseFilters(RestApiExceptionFilter)
async handleApiDelete(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.delete(request);
@ -51,6 +55,7 @@ export class RestApiCoreController {
}
@Post()
@UseFilters(RestApiExceptionFilter)
async handleApiPost(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.createOne(request);
@ -69,6 +74,7 @@ export class RestApiCoreController {
// We keep it to avoid a breaking change since it initially used PUT instead of PATCH,
// and because the PUT verb is often used as a PATCH.
@Put()
@UseFilters(RestApiExceptionFilter)
async handleApiPut(@Req() request: Request, @Res() res: Response) {
const result = await this.restApiCoreServiceV2.update(request);

View File

@ -2,54 +2,21 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { Request } from 'express';
import { capitalize } from 'twenty-shared/utils';
import { ObjectLiteral, OrderByCondition, SelectQueryBuilder } from 'typeorm';
import { ObjectRecord } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface';
import { GraphqlQueryFilterConditionParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-condition.parser';
import { GraphqlQueryOrderFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-order/graphql-query-order.parser';
import { CoreQueryBuilderFactory } from 'src/engine/api/rest/core/query-builder/core-query-builder.factory';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { FieldValue } from 'src/engine/api/rest/core/types/field-value.type';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { FieldMetadataMap } from 'src/engine/metadata-modules/types/field-metadata-map';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getObjectMetadataMapItemByNameSingular } from 'src/engine/metadata-modules/utils/get-object-metadata-map-item-by-name-singular.util';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-runner/services/api-event-emitter.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
interface FindManyMeta {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
totalCount: number;
}
interface FormatResultParams<T> {
operation: 'delete' | 'create' | 'update' | 'findOne' | 'findMany';
objectNameSingular?: string;
objectNamePlural?: string;
data: T;
meta?: FindManyMeta;
}
@Injectable()
export class RestApiCoreServiceV2 {
constructor(
private readonly coreQueryBuilderFactory: CoreQueryBuilderFactory,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly limitInputFactory: LimitInputFactory,
private readonly filterInputFactory: FilterInputFactory,
private readonly orderByInputFactory: OrderByInputFactory,
private readonly startingAfterInputFactory: StartingAfterInputFactory,
private readonly endingBeforeInputFactory: EndingBeforeInputFactory,
protected readonly apiEventEmitterService: ApiEventEmitterService,
) {}
@ -74,12 +41,8 @@ export class RestApiCoreServiceV2 {
objectMetadata.objectMetadataMapItem,
);
return this.formatResult({
operation: 'delete',
objectNameSingular: objectMetadataNameSingular,
data: {
id: recordToDelete.id,
},
return this.formatResult('delete', objectMetadataNameSingular, {
id: recordToDelete.id,
});
}
@ -96,11 +59,11 @@ export class RestApiCoreServiceV2 {
objectMetadata.objectMetadataMapItem,
);
return this.formatResult({
operation: 'create',
objectNameSingular: objectMetadataNameSingular,
data: createdRecord,
});
return this.formatResult(
'create',
objectMetadataNameSingular,
createdRecord,
);
}
async update(request: Request) {
@ -130,310 +93,24 @@ export class RestApiCoreServiceV2 {
objectMetadata.objectMetadataMapItem,
);
return this.formatResult({
operation: 'update',
objectNameSingular: objectMetadataNameSingular,
data: updatedRecord,
});
}
async get(request: Request) {
const { id: recordId } = parseCorePath(request);
const {
return this.formatResult(
'update',
objectMetadataNameSingular,
repository,
objectMetadata,
objectMetadataItemWithFieldsMaps,
} = await this.getRepositoryAndMetadataOrFail(request);
if (recordId) {
return await this.findOne(
repository,
recordId,
objectMetadataNameSingular,
);
} else {
return await this.findMany(
request,
repository,
objectMetadata,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
);
}
}
private async findOne(
repository: any,
recordId: string,
objectMetadataNameSingular: string,
) {
const record = await repository.findOne({
where: { id: recordId },
});
return this.formatResult({
operation: 'findOne',
objectNameSingular: objectMetadataNameSingular,
data: record,
});
}
private async findMany(
request: Request,
repository: WorkspaceRepository<ObjectLiteral>,
objectMetadata: any,
objectMetadataNameSingular: string,
objectMetadataItemWithFieldsMaps:
| ObjectMetadataItemWithFieldMaps
| undefined,
) {
// Get input parameters
const inputs = this.getPaginationInputs(request, objectMetadata);
// Create query builder
const qb = repository.createQueryBuilder(objectMetadataNameSingular);
// Apply filters with cursor
const { finalQuery } = await this.applyFiltersWithCursor(
qb,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
inputs,
);
// Get total count
const totalCount = await this.getTotalCount(finalQuery);
// Get records with pagination
const { finalRecords, hasMoreRecords } =
await this.getRecordsWithPagination(
finalQuery,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
inputs,
);
// Format and return result
return this.formatPaginatedResult(
finalRecords,
objectMetadataNameSingular,
objectMetadataItemWithFieldsMaps,
objectMetadata,
inputs.isForwardPagination,
hasMoreRecords,
totalCount,
updatedRecord,
);
}
private getPaginationInputs(request: Request, objectMetadata: any) {
const limit = this.limitInputFactory.create(request);
const filter = this.filterInputFactory.create(request, objectMetadata);
const orderBy = this.orderByInputFactory.create(request, objectMetadata);
const endingBefore = this.endingBeforeInputFactory.create(request);
const startingAfter = this.startingAfterInputFactory.create(request);
const isForwardPagination = !endingBefore;
return {
limit,
filter,
orderBy,
endingBefore,
startingAfter,
isForwardPagination,
};
}
private async applyFiltersWithCursor(
qb: SelectQueryBuilder<ObjectLiteral>,
objectMetadataNameSingular: string,
objectMetadataItemWithFieldsMaps:
| ObjectMetadataItemWithFieldMaps
| undefined,
inputs: {
filter: Record<string, FieldValue>;
orderBy: any;
startingAfter: string | undefined;
endingBefore: string | undefined;
isForwardPagination: boolean;
},
private formatResult<T>(
operation: 'delete' | 'create' | 'update' | 'find',
objectNameSingular: string,
data: T,
) {
const fieldMetadataMapByName =
objectMetadataItemWithFieldsMaps?.fieldsByName || {};
let appliedFilters = inputs.filter;
// Handle cursor-based filtering
if (inputs.startingAfter || inputs.endingBefore) {
const cursor = inputs.startingAfter || inputs.endingBefore;
try {
const cursorData = JSON.parse(
Buffer.from(cursor ?? '', 'base64').toString(),
);
// We always include ID in the ordering to ensure consistent pagination results
// Even if two records have identical values for the user-specified sort fields, their IDs ensures a deterministic order
const orderByWithIdCondition = [
...(inputs.orderBy || []),
{ id: 'ASC' },
];
const cursorFilter = await this.computeCursorFilter(
cursorData,
orderByWithIdCondition,
fieldMetadataMapByName,
inputs.isForwardPagination,
);
// Combine cursor filter with any user-provided filters
appliedFilters = inputs.filter
? { and: [inputs.filter, cursorFilter] }
: cursorFilter;
} catch (error) {
throw new BadRequestException(`Invalid cursor: ${cursor}`);
}
}
// Apply filters to query builder
const finalQuery = new GraphqlQueryFilterConditionParser(
fieldMetadataMapByName,
).parse(qb, objectMetadataNameSingular, appliedFilters);
return { finalQuery, appliedFilters };
}
private async getTotalCount(
query: SelectQueryBuilder<ObjectLiteral>,
): Promise<number> {
// Clone the query to avoid modifying the original query that will fetch records
const countQuery = query.clone();
return await countQuery.getCount();
}
private async getRecordsWithPagination(
query: SelectQueryBuilder<ObjectLiteral>,
objectMetadataNameSingular: string,
objectMetadataItemWithFieldsMaps:
| ObjectMetadataItemWithFieldMaps
| undefined,
inputs: {
orderBy: any;
limit: number;
isForwardPagination: boolean;
},
) {
const fieldMetadataMapByName =
objectMetadataItemWithFieldsMaps?.fieldsByName || {};
// Get parsed order by
const parsedOrderBy = new GraphqlQueryOrderFieldParser(
fieldMetadataMapByName,
).parse(inputs.orderBy, objectMetadataNameSingular);
// For backward pagination (endingBefore), we need to reverse the sort order
const finalOrderBy = inputs.isForwardPagination
? parsedOrderBy
: Object.entries(parsedOrderBy).reduce((acc, [key, direction]) => {
acc[key] = direction === 'ASC' ? 'DESC' : 'ASC';
return acc;
}, {});
// Fetch one extra record beyond the requested limit
// We'll remove it from the results before returning to the client
const records = await query
.orderBy(finalOrderBy as OrderByCondition)
.take(inputs.limit + 1)
.getMany();
// If we got more records than the limit, it means there are more pages
const hasMoreRecords = records.length > inputs.limit;
// Remove the extra record if we fetched more than requested
if (hasMoreRecords) {
records.pop();
}
// For backward pagination, we reversed the order to get the correct records
// Now we need to reverse them back to maintain the expected order for the client
const finalRecords = !inputs.isForwardPagination
? records.reverse()
: records;
return { finalRecords, hasMoreRecords };
}
private formatPaginatedResult(
finalRecords: any[],
objectMetadataNameSingular: string,
objectMetadataItemWithFieldsMaps: any,
objectMetadata: any,
isForwardPagination: boolean,
hasMoreRecords: boolean,
totalCount: number,
) {
return this.formatResult({
operation: 'findMany',
objectNamePlural: objectMetadataNameSingular,
data: formatGetManyData(
finalRecords,
objectMetadataItemWithFieldsMaps as any,
objectMetadata.objectMetadataMaps,
),
meta: {
hasNextPage: isForwardPagination && hasMoreRecords,
hasPreviousPage: !isForwardPagination && hasMoreRecords,
startCursor:
finalRecords.length > 0
? Buffer.from(JSON.stringify({ id: finalRecords[0].id })).toString(
'base64',
)
: null,
endCursor:
finalRecords.length > 0
? Buffer.from(
JSON.stringify({
id: finalRecords[finalRecords.length - 1].id,
}),
).toString('base64')
: null,
totalCount,
},
});
}
private formatResult<T>({
operation,
objectNameSingular,
objectNamePlural,
data,
meta,
}: FormatResultParams<T>) {
let prefix: string;
if (operation === 'findOne') {
prefix = objectNameSingular || '';
} else if (operation === 'findMany') {
prefix = objectNamePlural || '';
} else {
prefix = operation + capitalize(objectNameSingular || '');
}
const result = {
data: {
[prefix]: data,
[operation + capitalize(objectNameSingular)]: data,
},
};
if (meta) {
const { totalCount, ...rest } = meta;
(result.data as any).pageInfo = { ...rest };
(result.data as any).totalCount = totalCount;
}
return result;
}
@ -456,37 +133,13 @@ export class RestApiCoreServiceV2 {
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const objectMetadataItemWithFieldsMaps =
getObjectMetadataMapItemByNameSingular(
objectMetadata.objectMetadataMaps,
objectMetadataNameSingular,
);
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace<ObjectRecord>(
workspace.id,
objectMetadataNameSingular,
);
return {
objectMetadataNameSingular,
objectMetadata,
repository,
objectMetadataItemWithFieldsMaps,
};
}
// Helper method to compute cursor filter
private async computeCursorFilter(
cursorData: Record<string, any>,
orderByWithIdCondition: any[],
fieldMetadataMapByName: FieldMetadataMap,
isForwardPagination: boolean,
): Promise<any> {
return {
id: isForwardPagination ? { gt: cursorData.id } : { lt: cursorData.id },
};
return { objectMetadataNameSingular, objectMetadata, repository };
}
private getAuthContextFromRequest(request: Request): AuthContext {

View File

@ -6,10 +6,7 @@ import { objectMetadataItemMock } from 'src/engine/api/__mocks__/object-metadata
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
describe('OrderByInputFactory', () => {
const objectMetadata = {
objectMetadataItem: objectMetadataItemMock,
objectMetadataMapItem: objectMetadataItemMock,
};
const objectMetadata = { objectMetadataItem: objectMetadataItemMock };
let service: OrderByInputFactory;

View File

@ -72,7 +72,7 @@ export class OrderByInputFactory {
result = [...result, ...resultFields];
}
checkArrayFields(objectMetadata.objectMetadataMapItem, result);
checkArrayFields(objectMetadata.objectMetadataItem, result);
return result;
}

View File

@ -7,9 +7,7 @@ import { CoreQueryBuilderModule } from 'src/engine/api/rest/core/query-builder/c
import { RestApiCoreServiceV2 } from 'src/engine/api/rest/core/rest-api-core-v2.service';
import { RestApiCoreService } from 'src/engine/api/rest/core/rest-api-core.service';
import { EndingBeforeInputFactory } from 'src/engine/api/rest/input-factories/ending-before-input.factory';
import { FilterInputFactory } from 'src/engine/api/rest/input-factories/filter-input.factory';
import { LimitInputFactory } from 'src/engine/api/rest/input-factories/limit-input.factory';
import { OrderByInputFactory } from 'src/engine/api/rest/input-factories/order-by-input.factory';
import { StartingAfterInputFactory } from 'src/engine/api/rest/input-factories/starting-after-input.factory';
import { MetadataQueryBuilderModule } from 'src/engine/api/rest/metadata/query-builder/metadata-query-builder.module';
import { RestApiMetadataController } from 'src/engine/api/rest/metadata/rest-api-metadata.controller';
@ -42,8 +40,6 @@ import { ApiEventEmitterService } from 'src/engine/api/graphql/graphql-query-run
StartingAfterInputFactory,
EndingBeforeInputFactory,
LimitInputFactory,
FilterInputFactory,
OrderByInputFactory,
ApiEventEmitterService,
],
exports: [RestApiMetadataService],

View File

@ -1,252 +0,0 @@
import {
PERSON_1_ID,
PERSON_2_ID,
PERSON_3_ID,
} from 'test/integration/constants/mock-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe.skip('Core REST API Find Many endpoint', () => {
const testPersonIds = [PERSON_1_ID, PERSON_2_ID, PERSON_3_ID];
const testPersonCities: Record<string, string> = {};
beforeAll(async () => {
// Create test people with different cities for testing
for (const personId of testPersonIds) {
const city = generateRecordName(personId);
testPersonCities[personId] = city;
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: personId,
city: city,
// Add different jobTitles to test filtering
jobTitle: `Job ${personId.slice(0, 4)}`,
},
}).expect(201);
}
});
afterAll(async () => {
// Clean up test people
for (const personId of testPersonIds) {
await makeRestAPIRequest({
method: 'delete',
path: `/people/${personId}`,
}).expect(200);
}
});
it('5.a. should retrieve all people with pagination metadata', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people',
}).expect(200);
const people = response.body.data.people;
const pageInfo = response.body.data.pageInfo;
const totalCount = response.body.data.totalCount;
expect(people).toBeDefined();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length);
// Check that our test people are included in the results
for (const personId of testPersonIds) {
const person = people.find((p) => p.id === personId);
expect(person).toBeDefined();
expect(person.city).toBe(testPersonCities[personId]);
}
// Check pagination metadata
expect(pageInfo).toBeDefined();
expect(pageInfo.startCursor).toBeDefined();
expect(pageInfo.endCursor).toBeDefined();
expect(typeof totalCount).toBe('number');
expect(totalCount).toBeGreaterThanOrEqual(testPersonIds.length);
});
it('5.b. should limit results based on the limit parameter', async () => {
const limit = 2;
const response = await makeRestAPIRequest({
method: 'get',
path: `/people?limit=${limit}`,
}).expect(200);
const people = response.body.data.people;
expect(people).toBeDefined();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toBeLessThanOrEqual(limit);
});
it('5.c. should filter results based on filter parameters', async () => {
// Filter by jobTitle starting with "Job"
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter[jobTitle][contains]=Job',
}).expect(200);
const people = response.body.data.people;
expect(people).toBeDefined();
expect(Array.isArray(people)).toBe(true);
expect(people.length).toBeGreaterThanOrEqual(testPersonIds.length);
// All returned people should have jobTitle containing "Job"
for (const person of people) {
expect(person.jobTitle).toContain('Job');
}
});
it('5.d. should support cursor-based pagination with startingAfter', async () => {
// First, get initial results to get a cursor
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=1',
}).expect(200);
const firstPerson = initialResponse.body.data.people[0];
const endCursor = initialResponse.body.data.pageInfo.endCursor;
expect(firstPerson).toBeDefined();
expect(endCursor).toBeDefined();
// Now use the cursor to get the next page
const nextPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?startingAfter=${endCursor}&limit=1`,
}).expect(200);
const nextPagePeople = nextPageResponse.body.data.people;
expect(nextPagePeople).toBeDefined();
expect(nextPagePeople.length).toBe(1);
expect(nextPagePeople[0].id).not.toBe(firstPerson.id);
});
it('5.e. should support cursor-based pagination with endingBefore', async () => {
// First, get results to get a cursor from the second page
const initialResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?limit=2',
}).expect(200);
const people = initialResponse.body.data.people;
const startCursor = initialResponse.body.data.pageInfo.startCursor;
expect(people.length).toBe(2);
expect(startCursor).toBeDefined();
// Now use the cursor to get the previous page (which should be empty in this case)
const prevPageResponse = await makeRestAPIRequest({
method: 'get',
path: `/people?endingBefore=${startCursor}&limit=1`,
}).expect(200);
const prevPagePeople = prevPageResponse.body.data.people;
const pageInfo = prevPageResponse.body.data.pageInfo;
// Since we're at the beginning, there might not be previous results
expect(prevPagePeople).toBeDefined();
expect(pageInfo.hasPreviousPage).toBeDefined();
});
it('5.f. should support ordering of results', async () => {
// Order by city in ascending order
const ascResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?orderBy[city]=ASC',
}).expect(200);
const ascPeople = ascResponse.body.data.people;
// Check that cities are in ascending order
for (let i = 1; i < ascPeople.length; i++) {
if (ascPeople[i - 1].city && ascPeople[i].city) {
expect(ascPeople[i - 1].city <= ascPeople[i].city).toBe(true);
}
}
// Order by city in descending order
const descResponse = await makeRestAPIRequest({
method: 'get',
path: '/people?orderBy[city]=DESC',
}).expect(200);
const descPeople = descResponse.body.data.people;
// Check that cities are in descending order
for (let i = 1; i < descPeople.length; i++) {
if (descPeople[i - 1].city && descPeople[i].city) {
expect(descPeople[i - 1].city >= descPeople[i].city).toBe(true);
}
}
});
it('5.g. should return an UnauthorizedException when no token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people',
headers: { authorization: '' },
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
});
});
it('5.h. should return an UnauthorizedException when an invalid token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people',
headers: { authorization: 'Bearer invalid-token' },
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
});
});
it('5.i. should handle invalid cursor gracefully', async () => {
await makeRestAPIRequest({
method: 'get',
path: '/people?startingAfter=invalid-cursor',
})
.expect(400)
.expect((res) => {
expect(res.body.error).toBe('BadRequestException');
expect(res.body.messages[0]).toContain('Invalid cursor');
});
});
it('5.j. should combine filtering, ordering, and pagination', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: '/people?filter[jobTitle][contains]=Job&orderBy[city]=ASC&limit=2',
}).expect(200);
const people = response.body.data.people;
const pageInfo = response.body.data.pageInfo;
expect(people).toBeDefined();
expect(people.length).toBeLessThanOrEqual(2);
expect(pageInfo).toBeDefined();
// Check that all returned people match the filter
for (const person of people) {
expect(person.jobTitle).toContain('Job');
}
// Check that cities are in ascending order
for (let i = 1; i < people.length; i++) {
if (people[i - 1].city && people[i].city) {
expect(people[i - 1].city <= people[i].city).toBe(true);
}
}
});
});

View File

@ -1,93 +0,0 @@
import {
FAKE_PERSON_ID,
PERSON_1_ID,
} from 'test/integration/constants/mock-person-ids.constants';
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
import { generateRecordName } from 'test/integration/utils/generate-record-name';
describe.skip('Core REST API Find One endpoint', () => {
let personCity: string;
beforeAll(async () => {
personCity = generateRecordName(PERSON_1_ID);
// Create a test person to retrieve
await makeRestAPIRequest({
method: 'post',
path: '/people',
body: {
id: PERSON_1_ID,
city: personCity,
},
}).expect(201);
});
afterAll(async () => {
// Clean up the test person
await makeRestAPIRequest({
method: 'delete',
path: `/people/${PERSON_1_ID}`,
}).expect(200);
});
it('4.a. should retrieve a person by ID', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people/${PERSON_1_ID}`,
}).expect(200);
const person = response.body.data.person;
expect(person).toBeDefined();
expect(person.id).toBe(PERSON_1_ID);
expect(person.city).toBe(personCity);
});
it('4.b. should return null when trying to retrieve a non-existing person', async () => {
const response = await makeRestAPIRequest({
method: 'get',
path: `/people/${FAKE_PERSON_ID}`,
}).expect(200);
const person = response.body.data.person;
expect(person).toBeNull();
});
it('4.c. should return an UnauthorizedException when no token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${PERSON_1_ID}`,
headers: { authorization: '' },
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
});
});
it('4.d. should return an UnauthorizedException when an invalid token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${PERSON_1_ID}`,
headers: { authorization: 'Bearer invalid-token' },
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
});
});
it('4.e. should return an UnauthorizedException when an expired token is provided', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${PERSON_1_ID}`,
headers: { authorization: `Bearer ${EXPIRED_ACCESS_TOKEN}` },
})
.expect(401)
.expect((res) => {
expect(res.body.error).toBe('UNAUTHENTICATED');
expect(res.body.messages[0]).toBe('Token has expired.');
});
});
});