From 53afba29557d351d0d2b06c8f6315a7ccf7f9b61 Mon Sep 17 00:00:00 2001 From: Bohdan Melnyk Date: Wed, 1 Jul 2026 22:49:05 +0200 Subject: [PATCH] feat(api): paginate & filter GET /loans and GET /groups (closes #3) Both endpoints returned all rows unbounded. Add offset pagination with a { data, meta } envelope, a reusable PaginationQueryDto (page>=1 default 1, limit 1..100 default 20; a limit >100 is rejected 400 by the global ValidationPipe), plus sortBy(amount|requestedAt)+order for loans while keeping the existing groupId/status filters. Count and page fetched in one prisma.$transaction; Swagger @ApiQuery added for every param. Service-layer unit tests + DTO validation tests (10 tests, jest). --- jest.config.js | 14 ++++ src/common/dto/pagination-query.dto.spec.ts | 46 +++++++++++++ src/common/dto/pagination-query.dto.ts | 54 +++++++++++++++ src/modules/groups/groups.controller.ts | 13 ++-- src/modules/groups/groups.service.spec.ts | 41 +++++++++++ src/modules/groups/groups.service.ts | 25 +++++-- src/modules/loans/dto/find-loans-query.dto.ts | 21 ++++++ src/modules/loans/loans.controller.ts | 11 ++- src/modules/loans/loans.service.spec.ts | 69 +++++++++++++++++++ src/modules/loans/loans.service.ts | 28 +++++--- 10 files changed, 300 insertions(+), 22 deletions(-) create mode 100644 jest.config.js create mode 100644 src/common/dto/pagination-query.dto.spec.ts create mode 100644 src/common/dto/pagination-query.dto.ts create mode 100644 src/modules/groups/groups.service.spec.ts create mode 100644 src/modules/loans/dto/find-loans-query.dto.ts create mode 100644 src/modules/loans/loans.service.spec.ts diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..1ac60bb --- /dev/null +++ b/jest.config.js @@ -0,0 +1,14 @@ +/** @type {import('ts-jest').JestConfigWithTsJest} */ +module.exports = { + testEnvironment: "node", + // class-validator / class-transformer decorators need the reflect-metadata polyfill. + setupFiles: ["reflect-metadata"], + rootDir: "src", + testRegex: ".*\\.spec\\.ts$", + moduleFileExtensions: ["ts", "js", "json"], + // Transpile-only so unit tests don't fail on unrelated pre-existing type + // errors elsewhere in the graph; `tsc`/`nest build` still enforces types. + transform: { + "^.+\\.ts$": ["ts-jest", { isolatedModules: true }], + }, +}; diff --git a/src/common/dto/pagination-query.dto.spec.ts b/src/common/dto/pagination-query.dto.spec.ts new file mode 100644 index 0000000..8cfa176 --- /dev/null +++ b/src/common/dto/pagination-query.dto.spec.ts @@ -0,0 +1,46 @@ +import { plainToInstance } from "class-transformer"; +import { validateSync } from "class-validator"; +import { PaginationQueryDto, paginate } from "./pagination-query.dto"; + +// Mirrors the global ValidationPipe({ transform: true }) behaviour. +const parse = (raw: Record) => + plainToInstance(PaginationQueryDto, raw, { enableImplicitConversion: false }); + +describe("PaginationQueryDto", () => { + it("coerces numeric query strings", () => { + const dto = parse({ page: "2", limit: "50" }); + expect(validateSync(dto)).toHaveLength(0); + expect(dto.page).toBe(2); + expect(dto.limit).toBe(50); + }); + + it("rejects a limit above 100 (→ 400 via ValidationPipe)", () => { + const dto = parse({ limit: "101" }); + const errors = validateSync(dto); + expect(errors).toHaveLength(1); + expect(errors[0].property).toBe("limit"); + expect(errors[0].constraints).toHaveProperty("max"); + }); + + it("rejects page/limit below 1", () => { + expect(validateSync(parse({ page: "0" }))).toHaveLength(1); + expect(validateSync(parse({ limit: "0" }))).toHaveLength(1); + }); + + it("rejects non-integer values", () => { + expect(validateSync(parse({ limit: "abc" }))).toHaveLength(1); + }); +}); + +describe("paginate", () => { + it("builds the standard meta envelope", () => { + expect(paginate([{ id: 1 }], 143, 1, 20)).toEqual({ + data: [{ id: 1 }], + meta: { page: 1, limit: 20, total: 143, totalPages: 8 }, + }); + }); + + it("reports 0 total pages for an empty result set", () => { + expect(paginate([], 0, 1, 20).meta.totalPages).toBe(0); + }); +}); diff --git a/src/common/dto/pagination-query.dto.ts b/src/common/dto/pagination-query.dto.ts new file mode 100644 index 0000000..09ff317 --- /dev/null +++ b/src/common/dto/pagination-query.dto.ts @@ -0,0 +1,54 @@ +import { Type } from "class-transformer"; +import { IsInt, IsOptional, Max, Min } from "class-validator"; + +/** + * Reusable offset-pagination query parameters. + * + * Applied on top of the global `ValidationPipe({ transform: true })`, so the + * raw `?page=&limit=` query strings are coerced to numbers, validated and + * defaulted. A `limit` above 100 is rejected with a 400 by the pipe. + */ +export class PaginationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page: number = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit: number = 20; +} + +export interface PaginationMeta { + page: number; + limit: number; + total: number; + totalPages: number; +} + +export interface Paginated { + data: T[]; + meta: PaginationMeta; +} + +/** Wrap a page of rows and its total count into the standard response envelope. */ +export function paginate( + data: T[], + total: number, + page: number, + limit: number, +): Paginated { + return { + data, + meta: { + page, + limit, + total, + totalPages: total === 0 ? 0 : Math.ceil(total / limit), + }, + }; +} diff --git a/src/modules/groups/groups.controller.ts b/src/modules/groups/groups.controller.ts index 36b5907..0157edf 100644 --- a/src/modules/groups/groups.controller.ts +++ b/src/modules/groups/groups.controller.ts @@ -1,6 +1,7 @@ -import { Controller, Get, Post, Param, Body } from "@nestjs/common"; -import { ApiTags, ApiOperation } from "@nestjs/swagger"; +import { Controller, Get, Post, Param, Body, Query } from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger"; import { GroupsService, CreateGroupDto } from "./groups.service"; +import { PaginationQueryDto } from "../../common/dto/pagination-query.dto"; @ApiTags("groups") @Controller("groups") @@ -8,9 +9,11 @@ export class GroupsController { constructor(private readonly groupsService: GroupsService) {} @Get() - @ApiOperation({ summary: "List all groups" }) - findAll() { - return this.groupsService.findAll(); + @ApiOperation({ summary: "List groups (paginated)" }) + @ApiQuery({ name: "page", required: false, type: Number, description: "1-based page number (default 1)" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default 20, max 100)" }) + findAll(@Query() query: PaginationQueryDto) { + return this.groupsService.findAll(query); } @Get(":id") diff --git a/src/modules/groups/groups.service.spec.ts b/src/modules/groups/groups.service.spec.ts new file mode 100644 index 0000000..a2bd38c --- /dev/null +++ b/src/modules/groups/groups.service.spec.ts @@ -0,0 +1,41 @@ +import { GroupsService } from "./groups.service"; + +describe("GroupsService.findAll (pagination)", () => { + let service: GroupsService; + let prisma: any; + let stellar: any; + + beforeEach(() => { + prisma = { + group: { + findMany: jest.fn().mockReturnValue("FIND_MANY_QUERY"), + count: jest.fn().mockReturnValue("COUNT_QUERY"), + }, + $transaction: jest.fn(), + }; + stellar = { getBalance: jest.fn().mockResolvedValue("42") }; + service = new GroupsService(prisma, stellar); + }); + + it("paginates and enriches only the current page with balances", async () => { + const groups = [ + { id: "g1", adminAddress: "A1", treasuryContractId: "T1" }, + { id: "g2", adminAddress: "A2", treasuryContractId: null }, + ]; + prisma.$transaction.mockResolvedValue([groups, 5]); + + const result = await service.findAll({ page: 2, limit: 2 }); + + expect(prisma.group.findMany).toHaveBeenCalledWith({ + include: { members: true }, + orderBy: { createdAt: "desc" }, + skip: 2, + take: 2, + }); + expect(result.meta).toEqual({ page: 2, limit: 2, total: 5, totalPages: 3 }); + // group with a treasury contract is enriched via stellar, the other defaults to "0" + expect(result.data[0].balance).toBe("42"); + expect(result.data[1].balance).toBe("0"); + expect(stellar.getBalance).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/modules/groups/groups.service.ts b/src/modules/groups/groups.service.ts index 101a5be..70b760c 100644 --- a/src/modules/groups/groups.service.ts +++ b/src/modules/groups/groups.service.ts @@ -1,6 +1,10 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { StellarService } from "../../common/stellar.service"; +import { + paginate, + PaginationQueryDto, +} from "../../common/dto/pagination-query.dto"; export interface CreateGroupDto { name: string; @@ -20,14 +24,21 @@ export class GroupsService { private stellar: StellarService, ) {} - async findAll() { - const groups = await this.prisma.group.findMany({ - include: { members: true }, - orderBy: { createdAt: "desc" }, - }); + async findAll(query: PaginationQueryDto) { + const { page, limit } = query; + + const [groups, total] = await this.prisma.$transaction([ + this.prisma.group.findMany({ + include: { members: true }, + orderBy: { createdAt: "desc" }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.group.count(), + ]); // Enrich with on-chain balances - return Promise.all( + const data = await Promise.all( groups.map(async (g) => { const balance = g.treasuryContractId ? await this.stellar.getBalance(g.adminAddress, undefined) @@ -35,6 +46,8 @@ export class GroupsService { return { ...g, balance }; }) ); + + return paginate(data, total, page, limit); } async findOne(id: string) { diff --git a/src/modules/loans/dto/find-loans-query.dto.ts b/src/modules/loans/dto/find-loans-query.dto.ts new file mode 100644 index 0000000..5f45f31 --- /dev/null +++ b/src/modules/loans/dto/find-loans-query.dto.ts @@ -0,0 +1,21 @@ +import { IsIn, IsOptional, IsString } from "class-validator"; +import { PaginationQueryDto } from "../../../common/dto/pagination-query.dto"; + +/** Query parameters for `GET /api/loans`: pagination + existing filters + sort. */ +export class FindLoansQueryDto extends PaginationQueryDto { + @IsOptional() + @IsString() + groupId?: string; + + @IsOptional() + @IsString() + status?: string; + + @IsOptional() + @IsIn(["amount", "requestedAt"]) + sortBy: "amount" | "requestedAt" = "requestedAt"; + + @IsOptional() + @IsIn(["asc", "desc"]) + order: "asc" | "desc" = "desc"; +} diff --git a/src/modules/loans/loans.controller.ts b/src/modules/loans/loans.controller.ts index 934748b..5467526 100644 --- a/src/modules/loans/loans.controller.ts +++ b/src/modules/loans/loans.controller.ts @@ -1,6 +1,7 @@ import { Controller, Get, Post, Patch, Param, Body, Query } from "@nestjs/common"; import { ApiTags, ApiOperation, ApiQuery } from "@nestjs/swagger"; import { LoansService, CreateLoanDto } from "./loans.service"; +import { FindLoansQueryDto } from "./dto/find-loans-query.dto"; @ApiTags("loans") @Controller("loans") @@ -8,11 +9,15 @@ export class LoansController { constructor(private readonly loansService: LoansService) {} @Get() - @ApiOperation({ summary: "List loans, optionally filtered by group or status" }) + @ApiOperation({ summary: "List loans (paginated), optionally filtered by group or status" }) + @ApiQuery({ name: "page", required: false, type: Number, description: "1-based page number (default 1)" }) + @ApiQuery({ name: "limit", required: false, type: Number, description: "Items per page (default 20, max 100)" }) @ApiQuery({ name: "groupId", required: false }) @ApiQuery({ name: "status", required: false }) - findAll(@Query("groupId") groupId?: string, @Query("status") status?: string) { - return this.loansService.findAll(groupId, status); + @ApiQuery({ name: "sortBy", required: false, enum: ["amount", "requestedAt"] }) + @ApiQuery({ name: "order", required: false, enum: ["asc", "desc"] }) + findAll(@Query() query: FindLoansQueryDto) { + return this.loansService.findAll(query); } @Get(":id") diff --git a/src/modules/loans/loans.service.spec.ts b/src/modules/loans/loans.service.spec.ts new file mode 100644 index 0000000..93427ac --- /dev/null +++ b/src/modules/loans/loans.service.spec.ts @@ -0,0 +1,69 @@ +import { LoansService } from "./loans.service"; +import { FindLoansQueryDto } from "./dto/find-loans-query.dto"; + +describe("LoansService.findAll (pagination)", () => { + let service: LoansService; + let prisma: any; + + beforeEach(() => { + prisma = { + loan: { + findMany: jest.fn().mockReturnValue("FIND_MANY_QUERY"), + count: jest.fn().mockReturnValue("COUNT_QUERY"), + }, + // $transaction resolves the [findMany, count] batch → [rows, total] + $transaction: jest.fn(), + }; + service = new LoansService(prisma, {} as any); + }); + + const query = (overrides: Partial = {}): FindLoansQueryDto => ({ + page: 1, + limit: 20, + sortBy: "requestedAt", + order: "desc", + ...overrides, + }); + + it("applies defaults (page 1, limit 20) and wraps rows in the envelope", async () => { + const rows = [{ id: "l1" }, { id: "l2" }]; + prisma.$transaction.mockResolvedValue([rows, 143]); + + const result = await service.findAll(query()); + + expect(result.data).toBe(rows); + expect(result.meta).toEqual({ page: 1, limit: 20, total: 143, totalPages: 8 }); + expect(prisma.loan.findMany).toHaveBeenCalledWith({ + where: {}, + orderBy: { requestedAt: "desc" }, + skip: 0, + take: 20, + }); + expect(prisma.loan.count).toHaveBeenCalledWith({ where: {} }); + }); + + it("computes skip/take/where/orderBy from page, limit, filters and sort", async () => { + prisma.$transaction.mockResolvedValue([[], 260]); + + const result = await service.findAll( + query({ page: 3, limit: 50, groupId: "g1", status: "Pending", sortBy: "amount", order: "asc" }), + ); + + expect(prisma.loan.findMany).toHaveBeenCalledWith({ + where: { groupId: "g1", status: "Pending" }, + orderBy: { amount: "asc" }, + skip: 100, + take: 50, + }); + expect(result.meta).toEqual({ page: 3, limit: 50, total: 260, totalPages: 6 }); + }); + + it("returns totalPages 0 when there are no matching loans", async () => { + prisma.$transaction.mockResolvedValue([[], 0]); + + const result = await service.findAll(query()); + + expect(result.meta.total).toBe(0); + expect(result.meta.totalPages).toBe(0); + }); +}); diff --git a/src/modules/loans/loans.service.ts b/src/modules/loans/loans.service.ts index 32538ce..7c94f30 100644 --- a/src/modules/loans/loans.service.ts +++ b/src/modules/loans/loans.service.ts @@ -1,6 +1,8 @@ import { Injectable, NotFoundException } from "@nestjs/common"; import { PrismaService } from "../../common/prisma.service"; import { NotificationsService } from "../notifications/notifications.service"; +import { paginate } from "../../common/dto/pagination-query.dto"; +import { FindLoansQueryDto } from "./dto/find-loans-query.dto"; export interface CreateLoanDto { groupId: string; @@ -18,14 +20,24 @@ export class LoansService { private notifications: NotificationsService, ) {} - async findAll(groupId?: string, status?: string) { - return this.prisma.loan.findMany({ - where: { - ...(groupId ? { groupId } : {}), - ...(status ? { status } : {}), - }, - orderBy: { requestedAt: "desc" }, - }); + async findAll(query: FindLoansQueryDto) { + const { page, limit, groupId, status, sortBy, order } = query; + const where = { + ...(groupId ? { groupId } : {}), + ...(status ? { status } : {}), + }; + + const [data, total] = await this.prisma.$transaction([ + this.prisma.loan.findMany({ + where, + orderBy: { [sortBy]: order }, + skip: (page - 1) * limit, + take: limit, + }), + this.prisma.loan.count({ where }), + ]); + + return paginate(data, total, page, limit); } async findOne(id: string) {