diff --git a/src/domain-services/categories/category-service.ts b/src/domain-services/categories/category-service.ts new file mode 100644 index 00000000..76f7307d --- /dev/null +++ b/src/domain-services/categories/category-service.ts @@ -0,0 +1,44 @@ +import { Database } from '@unocha/hpc-api-core/src/db'; +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { Service } from 'typedi'; +import { FlowCategory } from '../flows/graphql/types'; + +@Service() +export class CategoryService { + async getFlowCategory(flow: any, models: Database): Promise { + const flowIdBranded = createBrandedValue(flow.id); + const flowLinks = await models.flowLink.find({ + where: { + childID: flowIdBranded, + }, + }); + + const flowLinksBrandedIds = flowLinks.map((flowLink) => + createBrandedValue(flowLink.parentID) + ); + + const categoriesRef = await models.categoryRef.find({ + where: { + objectID: { + [Op.IN]: flowLinksBrandedIds, + }, + versionID: flow.versionID, + }, + }); + + const categories = await models.category.find({ + where: { + id: { + [Op.IN]: categoriesRef.map((catRef) => catRef.categoryID), + }, + }, + }); + + return categories.map((cat) => ({ + id: cat.id, + name: cat.name, + group: cat.group, + })); + } +} diff --git a/src/domain-services/flows/flow-search-service.ts b/src/domain-services/flows/flow-search-service.ts new file mode 100644 index 00000000..c48d3ade --- /dev/null +++ b/src/domain-services/flows/flow-search-service.ts @@ -0,0 +1,171 @@ +import { Service } from 'typedi'; +import { + FlowCategory, + FlowLocation, + FlowOrganization, + FlowPlan, + FlowSearchResult, + FlowSortField, + FlowUsageYear, +} from './graphql/types'; +import { Database } from '@unocha/hpc-api-core/src/db/type'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { OrganizationService } from '../organizations/organization-service'; +import { LocationService } from '../location/location-service'; +import { PlanService } from '../plans/plan-service'; +import { UsageYearService } from '../usage-years/usage-year-service'; +import { CategoryService } from '../categories/category-service'; +import { prepareConditionFromCursor } from '../../utils/graphql/pagination'; + +@Service() +export class FlowSearchService { + constructor( + private readonly organizationService: OrganizationService, + private readonly locationService: LocationService, + private readonly planService: PlanService, + private readonly usageYearService: UsageYearService, + private readonly categoryService: CategoryService + ) {} + + async search( + models: Database, + first: number, + afterCursor?: number, + beforeCursor?: number, + sortField?: FlowSortField, + sortOrder?: 'asc' | 'desc' + ): Promise { + if (beforeCursor && afterCursor) { + throw new Error('Cannot use before and after cursor at the same time'); + } + + const sortCondition = { + column: sortField ?? 'id', + order: sortOrder ?? 'desc', + }; + + let flows; + const countRes = await models.flow.count(); + const count = countRes[0] as { count: number }; + + const hasCursor = afterCursor || beforeCursor; + + if (hasCursor) { + const condition = prepareConditionFromCursor( + sortCondition, + afterCursor, + beforeCursor + ); + + flows = await models.flow.find({ + orderBy: sortCondition, + limit: first, + where: { + ...condition, + }, + }); + } else { + flows = await models.flow.find({ + orderBy: sortCondition, + limit: first, + }); + } + + const items = await Promise.all( + flows.map(async (flow) => { + const categories: FlowCategory[] = + await this.categoryService.getFlowCategory(flow, models); + + const organizationsFO: any[] = []; + const locationsFO: any[] = []; + const plansFO: any[] = []; + const usageYearsFO: any[] = []; + + await this.getFlowObjects( + flow, + models, + organizationsFO, + locationsFO, + plansFO, + usageYearsFO + ); + + const organizationsPromise: Promise = + this.organizationService.getFlowObjectOrganizations( + organizationsFO, + models + ); + + const locationsPromise: Promise = + this.locationService.getFlowObjectLocations(locationsFO, models); + + const plansPromise: Promise = + this.planService.getFlowObjectPlans(plansFO, models); + + const usageYearsPromise: Promise = + this.usageYearService.getFlowObjectUsageYears(usageYearsFO, models); + + const [organizations, locations, plans, usageYears] = await Promise.all( + [ + organizationsPromise, + locationsPromise, + plansPromise, + usageYearsPromise, + ] + ); + + return { + id: flow.id.valueOf(), + amountUSD: flow.amountUSD.toString(), + createdAt: flow.createdAt, + categories: categories, + organizations: organizations, + locations: locations, + plans: plans, + usageYears: usageYears, + cursor: flow.id.valueOf(), + }; + }) + ); + + return { + items, + hasNextPage: first <= flows.length, + hasPreviousPage: afterCursor !== undefined, + startCursor: flows.length ? flows[0].id.valueOf() : 0, + endCursor: flows.length ? flows[flows.length - 1].id.valueOf() : 0, + pageSize: flows.length, + sortField: sortCondition.column, + sortOrder: sortCondition.order, + total: count.count, + }; + } + + private async getFlowObjects( + flow: any, + models: Database, + organizationsFO: any[], + locationsFO: any[], + plansFO: any[], + usageYearsFO: any[] + ): Promise { + const flowIdBranded = createBrandedValue(flow.id); + const flowObjects = await models.flowObject.find({ + where: { + flowID: flowIdBranded, + }, + }); + + flowObjects.forEach((flowObject) => { + if (flowObject.objectType === 'organization') { + organizationsFO.push(flowObject); + } else if (flowObject.objectType === 'location') { + locationsFO.push(flowObject); + } else if (flowObject.objectType === 'plan') { + plansFO.push(flowObject); + } else if (flowObject.objectType === 'usageYear') { + usageYearsFO.push(flowObject); + } + }); + } +} diff --git a/src/domain-services/flows/flow-service.ts b/src/domain-services/flows/flow-service.ts deleted file mode 100644 index 0a63abe2..00000000 --- a/src/domain-services/flows/flow-service.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { Service } from 'typedi'; -import { - FlowCategory, - FlowLocation, - FlowOrganization, - FlowPlan, - FlowSearchResult, - FlowSortField, - FlowUsageYear, -} from './graphql/types'; -import { Database } from '@unocha/hpc-api-core/src/db/type'; -import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; -import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; -import { OrganizationService } from '../organizations/organization-service'; -import { LocationService } from '../location/location-service'; -import { PlanService } from '../plans/plan-service'; -import { UsageYearService } from '../usage-years/usage-year-service'; - -@Service() -export class FlowService { - constructor(private readonly organizationService: OrganizationService, - private readonly locationService: LocationService, - private readonly planService: PlanService, - private readonly usageYearService: UsageYearService) {} - - async search( - models: Database, - first: number, - afterCursor: string, - sortField: FlowSortField, - sortOrder: 'asc' | 'desc' - ): Promise { - let afterIndex = 0; - - const sortCondition = { - column: sortField ?? 'id', - order: sortOrder ?? 'DESC', - }; - - let flows = await models.flow.find({ - orderBy: sortCondition, - limit: first, - }); - const countRes = await models.flow.count(); - const count = countRes[0] as { count: number }; - - if (afterCursor) { - const after = flows.findIndex( - (flow) => flow.id.toString() === afterCursor - ); - if (after < 0) { - throw new Error('Cursor not found'); - } - afterIndex = after + 1; - } - - const pagedData = flows.slice(afterIndex, afterIndex + first); - const edges = await Promise.all( - pagedData.map(async (flow) => { - const categories: FlowCategory[] = await this.getFlowCategories( - flow, - models - ); - - const flowObjects = await this.getFlowObjects(flow, models); - - const organizationsFO: any[] = []; - const locationsFO: any[] = []; - const plansFO: any[] = []; - const usageYearsFO: any[] = []; - - flowObjects.forEach((flowObject) => { - if (flowObject.objectType === 'organization') { - organizationsFO.push(flowObject); - } else if (flowObject.objectType === 'location') { - locationsFO.push(flowObject); - } else if (flowObject.objectType === 'plan') { - plansFO.push(flowObject); - } else if (flowObject.objectType === 'usageYear') { - usageYearsFO.push(flowObject); - } - }); - - const organizations: FlowOrganization[] = await this.organizationService.getFlowObjectOrganizations( - organizationsFO, - models - ); - - const locations: FlowLocation[] = await this.locationService.getFlowObjectLocations( - locationsFO, - models - ); - - const plans = await this.planService.getFlowObjectPlans(plansFO, models); - - const usageYears = await this.usageYearService.getFlowObjectUsageYears(usageYearsFO, models); - - return { - node: { - id: flow.id.valueOf(), - amountUSD: flow.amountUSD.toString(), - createdAt: flow.createdAt, - categories: categories, - organizations: organizations, - locations: locations, - plans: plans, - usageYears: usageYears, - }, - cursor: flow.id.toString(), - }; - }) - ); - - return { - edges, - pageInfo: { - hasNextPage: count.count > afterIndex, - hasPreviousPage: afterIndex > 0, - startCursor: pagedData.length ? pagedData[0].id.toString() : '', - endCursor: pagedData.length - ? pagedData[pagedData.length - 1].id.toString() - : '', - pageSize: pagedData.length, - sortField: sortCondition.column, - sortOrder: sortCondition.order, - }, - totalCount: count.count, - }; - } - - private async getFlowCategories( - flow: any, - models: Database - ): Promise { - const flowIdBranded = createBrandedValue(flow.id); - const flowLinks = await models.flowLink.find({ - where: { - childID: flowIdBranded, - }, - }); - - const flowLinksBrandedIds = flowLinks.map((flowLink) => - createBrandedValue(flowLink.parentID) - ); - - const categoriesRef = await models.categoryRef.find({ - where: { - objectID: { - [Op.IN]: flowLinksBrandedIds, - }, - versionID: flow.versionID, - }, - }); - - const categories = await models.category.find({ - where: { - id: { - [Op.IN]: categoriesRef.map((catRef) => catRef.categoryID), - }, - }, - }); - - return categories.map((cat) => ({ - id: cat.id, - name: cat.name, - group: cat.group, - })); - } - - private async getFlowObjects(flow: any, models: Database): Promise { - const flowIdBranded = createBrandedValue(flow.id); - const flowObjects = await models.flowObject.find({ - where: { - flowID: flowIdBranded, - }, - }); - - return flowObjects; - } -} diff --git a/src/domain-services/flows/graphql/resolver.ts b/src/domain-services/flows/graphql/resolver.ts index baebb962..b78c4393 100644 --- a/src/domain-services/flows/graphql/resolver.ts +++ b/src/domain-services/flows/graphql/resolver.ts @@ -1,19 +1,20 @@ -import Flow, { FlowSearchResult, FlowSortField } from './types'; +import Flow, { FlowSearchResult } from './types'; import { Service } from 'typedi'; -import { Arg, Args, Ctx, Query, Resolver } from 'type-graphql'; -import { FlowService } from '../flow-service'; +import { Arg, Ctx, Query, Resolver } from 'type-graphql'; +import { FlowSearchService } from '../flow-search-service'; import Context from '../../Context'; @Service() @Resolver(Flow) export default class FlowResolver { - constructor(private flowService: FlowService) {} + constructor(private flowSearchService: FlowSearchService) {} @Query(() => FlowSearchResult) async searchFlows( @Ctx() context: Context, @Arg('first', { nullable: false }) first: number, - @Arg('afterCursor', { nullable: true }) afterCursor: string, + @Arg('afterCursor', { nullable: true }) afterCursor: number, + @Arg('beforeCursor', { nullable: true }) beforeCursor: number, @Arg('sortField', { nullable: true }) sortField: | 'id' @@ -38,10 +39,11 @@ export default class FlowResolver { | 'deletedAt', @Arg('sortOrder', { nullable: true }) sortOrder: 'asc' | 'desc' ): Promise { - return await this.flowService.search( + return await this.flowSearchService.search( context.models, first, afterCursor, + beforeCursor, sortField, sortOrder ); diff --git a/src/domain-services/flows/graphql/types.ts b/src/domain-services/flows/graphql/types.ts index f8864e51..5efe1be2 100644 --- a/src/domain-services/flows/graphql/types.ts +++ b/src/domain-services/flows/graphql/types.ts @@ -1,4 +1,5 @@ import { Field, ObjectType } from 'type-graphql'; +import { ItemPaged, PageInfo } from '../../../utils/graphql/pagination'; @ObjectType() export class FlowCategory { @@ -52,7 +53,7 @@ export class FlowUsageYear { } @ObjectType() -export default class Flow { +export default class Flow implements ItemPaged { @Field({ nullable: false }) id: number; @@ -76,70 +77,15 @@ export default class Flow { @Field(() => [FlowUsageYear], { nullable: false }) usageYears: FlowUsageYear[]; -} - -@ObjectType() -export class FlowEdge { - @Field({ nullable: false }) - node: Flow; @Field({ nullable: false }) - cursor: string; + cursor: number; } @ObjectType() -export class PageInfo { - @Field({ nullable: false }) - hasNextPage: boolean; - - @Field({ nullable: false }) - hasPreviousPage: boolean; - - @Field({ nullable: false }) - startCursor: string; - - @Field({ nullable: false }) - endCursor: string; - - @Field({ nullable: false }) - pageSize: number; - - @Field({ nullable: false }) - sortField: - | 'id' - | 'amountUSD' - | 'versionID' - | 'activeStatus' - | 'restricted' - | 'newMoney' - | 'flowDate' - | 'decisionDate' - | 'firstReportedDate' - | 'budgetYear' - | 'origAmount' - | 'origCurrency' - | 'exchangeRate' - | 'description' - | 'notes' - | 'versionStartDate' - | 'versionEndDate' - | 'createdAt' - | 'updatedAt' - | 'deletedAt'; - - @Field({ nullable: false }) - sortOrder: string; -} -@ObjectType() -export class FlowSearchResult { - @Field(() => [FlowEdge], { nullable: false }) - edges: FlowEdge[]; - - @Field(() => PageInfo, { nullable: false }) - pageInfo: PageInfo; - - @Field({ nullable: false }) - totalCount: number; +export class FlowSearchResult extends PageInfo { + @Field(() => [Flow], { nullable: false }) + items: Flow[]; } export type FlowSortField = diff --git a/src/domain-services/organizations/organization-service.ts b/src/domain-services/organizations/organization-service.ts index 36e5a525..66805bb8 100644 --- a/src/domain-services/organizations/organization-service.ts +++ b/src/domain-services/organizations/organization-service.ts @@ -1,24 +1,23 @@ -import { Database } from "@unocha/hpc-api-core/src/db"; -import { Service } from "typedi"; +import { Database } from '@unocha/hpc-api-core/src/db'; +import { Service } from 'typedi'; import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; @Service() export class OrganizationService { + async getFlowObjectOrganizations(organizationsFO: any[], models: Database) { + const organizations = await models.organization.find({ + where: { + id: { + [Op.IN]: organizationsFO.map((orgFO) => orgFO.objectID), + }, + }, + }); - async getFlowObjectOrganizations(organizationsFO: any[], models: Database){ - const organizations = await models.organization.find({ - where: { - id: { - [Op.IN]: organizationsFO.map((orgFO) => orgFO.objectID), - }, - }, - }); - - return organizations.map((org) => ({ - id: org.id, - refDirection: organizationsFO.find((orgFO) => orgFO.objectID === org.id) - .refDirection, - name: org.name, - })); - } -} \ No newline at end of file + return organizations.map((org) => ({ + id: org.id, + refDirection: organizationsFO.find((orgFO) => orgFO.objectID === org.id) + .refDirection, + name: org.name, + })); + } +} diff --git a/src/utils/graphql/pagination.ts b/src/utils/graphql/pagination.ts new file mode 100644 index 00000000..bdc8008c --- /dev/null +++ b/src/utils/graphql/pagination.ts @@ -0,0 +1,64 @@ +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { createBrandedValue } from '@unocha/hpc-api-core/src/util/types'; +import { ObjectType, Field } from 'type-graphql'; + +export interface ItemPaged { + cursor: number; +} + +@ObjectType() +export class PageInfo { + @Field({ nullable: false }) + hasNextPage: boolean; + + @Field({ nullable: false }) + hasPreviousPage: boolean; + + @Field({ nullable: false }) + startCursor: number; + + @Field({ nullable: false }) + endCursor: number; + + @Field({ nullable: false }) + pageSize: number; + + @Field(() => String, { nullable: false }) + sortField: TSortFields; + + @Field({ nullable: false }) + sortOrder: string; + + @Field({ nullable: false }) + total: number; +} + +export function prepareConditionFromCursor( + sortCondition: { column: string; order: 'asc' | 'desc' }, + afterCursor?: number, + beforeCursor?: number +): any { + if (afterCursor && beforeCursor) { + throw new Error('Cannot use before and after cursor at the same time'); + } + + if (afterCursor || beforeCursor) { + const isAscending = sortCondition.order === 'asc'; + const cursorValue = afterCursor || beforeCursor; + + let op; + if (isAscending) { + op = afterCursor ? Op.GT : Op.LT; + } else { + op = beforeCursor ? Op.GT : Op.LT; + } + + return { + id: { + [op]: createBrandedValue(cursorValue), + }, + }; + } + + return {}; +} diff --git a/tests/resolvers/flows.spec.ts b/tests/resolvers/flows.spec.ts index 53f9957d..364ed210 100644 --- a/tests/resolvers/flows.spec.ts +++ b/tests/resolvers/flows.spec.ts @@ -1,22 +1,283 @@ import ContextProvider from '../testContext'; +const fullQuery = `query { + searchFlows(first: 10, sortOrder: "DESC", sortField: "id") { + total + + items { + id + + createdAt + + amountUSD + + categories { + name + + group + } + + organizations { + refDirection + name + } + + locations { + name + } + + plans { + name + } + + usageYears { + year + direction + } + + cursor + } + + startCursor + + hasNextPage + + endCursor + + hasPreviousPage + + pageSize + } +}`; + +const simpliedQuery = `query { + searchFlows( + first: 10 + sortOrder: "DESC" + sortField: "id" + ) { + total + + items { + id + + createdAt + + amountUSD + + cursor + } + + startCursor + + hasNextPage + + endCursor + + hasPreviousPage + + pageSize + } +}`; describe('Query should return Flow search', () => { - - it('All data should be returned', async () => { - const response = await ContextProvider.Instance.apolloTestServer.executeOperation({ - query: 'query { searchFlows (first:10) { totalCount edges { node { id createdAt amountUSD category } cursor } pageInfo { startCursor hasNextPage endCursor hasPreviousPage pageSize } } }', - - }); - - expect(response).toBeDefined(); - expect(response.errors).toBeUndefined(); - expect(response.data).toBeDefined(); - const data = response.data as any; - expect(data.flows).toBeDefined(); - expect(data.flows.length).toBeGreaterThan(0); - const flows = data.flows[0]; - expect(flows.id).toBeDefined(); + it('All data should be returned', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: fullQuery, }); -}); + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + + const data = response.data as any; + expect(data.searchFlows).toBeDefined(); + + const searchFlowsResponse = data.searchFlows; + expect(searchFlowsResponse.pageSize).toBe(10); + expect(searchFlowsResponse.hasPreviousPage).toBe(false); + expect(searchFlowsResponse.hasNextPage).toBe(true); + expect(searchFlowsResponse.endCursor).toBeDefined(); + expect(searchFlowsResponse.startCursor).toBeDefined(); + expect(searchFlowsResponse.total).toBeDefined(); + expect(searchFlowsResponse.items).toBeDefined(); + + const flows = searchFlowsResponse.items; + expect(flows.length).toBe(10); + + const flow = flows[0]; + expect(flow.id).toBeDefined(); + expect(flow.cursor).toBeDefined(); + expect(flow.createdAt).toBeDefined(); + expect(flow.amountUSD).toBeDefined(); + expect(flow.categories).toBeDefined(); + expect(flow.categories.length).toBeGreaterThan(0); + expect(flow.organizations).toBeDefined(); + expect(flow.organizations.length).toBeGreaterThan(0); + expect(flow.locations).toBeDefined(); + expect(flow.locations.length).toBeGreaterThan(0); + expect(flow.plans).toBeDefined(); + expect(flow.plans.length).toBeGreaterThan(0); + expect(flow.usageYears).toBeDefined(); + expect(flow.usageYears.length).toBeGreaterThan(0); + }); + + it('All data should be returned', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: simpliedQuery, + }); + + expect(response).toBeDefined(); + expect(response.errors).toBeUndefined(); + expect(response.data).toBeDefined(); + + const data = response.data as any; + expect(data.searchFlows).toBeDefined(); + + const searchFlowsResponse = data.searchFlows; + expect(searchFlowsResponse.pageSize).toBe(10); + expect(searchFlowsResponse.hasPreviousPage).toBe(false); + expect(searchFlowsResponse.hasNextPage).toBe(true); + expect(searchFlowsResponse.endCursor).toBeDefined(); + expect(searchFlowsResponse.startCursor).toBeDefined(); + expect(searchFlowsResponse.total).toBeDefined(); + expect(searchFlowsResponse.items).toBeDefined(); + + const flows = searchFlowsResponse.items; + expect(flows.length).toBe(10); + + const flow = flows[0]; + expect(flow.id).toBeDefined(); + expect(flow.cursor).toBeDefined(); + expect(flow.createdAt).toBeDefined(); + expect(flow.amountUSD).toBeDefined(); + + expect(flow.categories).toBeUndefined(); + expect(flow.organizations).toBeUndefined(); + expect(flow.locations).toBeUndefined(); + expect(flow.plans).toBeUndefined(); + expect(flow.usageYears).toBeUndefined(); + }); + + it('Should return error when invalid sort field', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: `query { + searchFlows( + first: 10 + sortOrder: "DESC" + sortField: "invalid" + ) { + total + + items { + id + + createdAt + + amountUSD + + cursor + } + } + }`, + }); + + expect(response).toBeDefined(); + expect(response.errors).toBeDefined(); + expect(response.data).toBeNull(); + }); + + it('Should return error when invalid afterCursor', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: `query { + searchFlows( + first: 10 + sortOrder: "DESC" + sortField: "id" + afterCursor: "invalid" + ) { + total + + items { + id + + createdAt + + amountUSD + + cursor + } + } + }`, + }); + + expect(response).toBeDefined(); + expect(response.errors).toBeDefined(); + expect(response.data).toBeUndefined(); + }); + + it('Should return error when invalid beforeCursor', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: `query { + searchFlows( + first: 10 + sortOrder: "DESC" + sortField: "id" + bedoreCursor: "invalid" + ) { + total + + items { + id + + createdAt + + amountUSD + + cursor + } + } + }`, + }); + + expect(response).toBeDefined(); + expect(response.errors).toBeDefined(); + expect(response.data).toBeUndefined(); + }); + + it('Should return error when both afterCursor and beforeCursor are provided', async () => { + const response = + await ContextProvider.Instance.apolloTestServer.executeOperation({ + query: `query { + searchFlows( + first: 10 + sortOrder: "DESC" + sortField: "id" + afterCursor: "20" + beforeCursor: "40" + ) { + total + + items { + id + + createdAt + + amountUSD + + cursor + } + } + }`, + }); + + expect(response).toBeDefined(); + expect(response.errors).toBeDefined(); + expect(response.data).toBeUndefined(); + }); +}); diff --git a/tests/unit/pagination.spec.ts b/tests/unit/pagination.spec.ts new file mode 100644 index 00000000..8d889a08 --- /dev/null +++ b/tests/unit/pagination.spec.ts @@ -0,0 +1,72 @@ +import { Op } from '@unocha/hpc-api-core/src/db/util/conditions'; +import { prepareConditionFromCursor } from '../../src/utils/graphql/pagination'; + +describe('Based on cursor and order for pagination', () => { + describe('Order is asc', () => { + const sortCondition = { column: 'id', order: 'asc' as const }; + + it("Should return 'GT' when afterCursor is defined", () => { + const afterCursor = 1; + const beforeCursor = undefined; + const result = prepareConditionFromCursor( + sortCondition, + afterCursor, + beforeCursor + ); + expect(result.id).toEqual({ [Op.GT]: afterCursor }); + }); + + it("Should return 'LT' when beforeCursor is defined", () => { + const afterCursor = undefined; + const beforeCursor = 1; + const result = prepareConditionFromCursor( + sortCondition, + afterCursor, + beforeCursor + ); + expect(result.id).toEqual({ [Op.LT]: beforeCursor }); + }); + + it('Should throw an error when both afterCursor and beforeCursor are defined', () => { + const afterCursor = 1; + const beforeCursor = 2; + expect(() => + prepareConditionFromCursor(sortCondition, afterCursor, beforeCursor) + ).toThrowError('Cannot use before and after cursor at the same time'); + }); + }); + + describe("Order is 'desc'", () => { + const sortCondition = { column: 'id', order: 'desc' as const }; + + it("Should return 'LT' when afterCursor is defined", () => { + const afterCursor = 1; + const beforeCursor = undefined; + const result = prepareConditionFromCursor( + sortCondition, + afterCursor, + beforeCursor + ); + expect(result.id).toEqual({ [Op.LT]: afterCursor }); + }); + + it("Should return 'GT' when beforeCursor is defined", () => { + const afterCursor = undefined; + const beforeCursor = 1; + const result = prepareConditionFromCursor( + sortCondition, + afterCursor, + beforeCursor + ); + expect(result.id).toEqual({ [Op.GT]: beforeCursor }); + }); + + it('Should throw an error when both afterCursor and beforeCursor are defined', () => { + const afterCursor = 1; + const beforeCursor = 2; + expect(() => + prepareConditionFromCursor(sortCondition, afterCursor, beforeCursor) + ).toThrowError('Cannot use before and after cursor at the same time'); + }); + }); +});