diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b37ab97 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,13 @@ +# Changelog + +All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] +### Added +- Shared pagination utility `applyPaginationDefaults` with configurable `MAX_PAGE_SIZE` (default **100**). +- Unit tests covering pagination helper edge cases. +- Integration tests validating enforced pagination across list endpoints. + +### Changed +- List endpoints for Hotels, Customers, Rooms, and Reservations now automatically enforce a maximum page size of 100 records when the client omits or exceeds this limit. Negative `skip` values are clamped to `0`. (**Performance fix**, closes #84) + diff --git a/README.md b/README.md index 707645a..2c60776 100644 --- a/README.md +++ b/README.md @@ -227,3 +227,19 @@ This project is licensed under the **Apache License 2.0** – see the [LICENSE]( * Generated with [Amplication](https://amplication.com) – an open-source platform for building Node.js applications. * Built with amazing open-source software: * [NestJS](https://nestjs.com) • [Prisma](https://www.prisma.io) • [React](https://react.dev) • [React-Admin](https://marmelab.com/react-admin/) • and many more. + +--- + +## API Pagination & List Limits + +To ensure stable performance and prevent accidental full-table scans, the server enforces a **maximum page size of 100 records** on all list (findMany) endpoints. If a request omits the `take` query parameter—or specifies a value greater than 100—the response will be limited to 100 items. + +Guidelines: + +1. Always include an explicit `take` parameter when paginating large datasets. +2. Values of `take` greater than `100` are automatically reduced to `100`. +3. Negative `skip` values are coerced to `0`. + +The limit currently applies to the following resources: **hotels**, **customers**, **rooms**, and **reservations**. + +_Introduced in vNEXT – see [CHANGELOG](./CHANGELOG.md) for details._ diff --git a/apps/hotel-management-service-server/src/customer/customer.service.ts b/apps/hotel-management-service-server/src/customer/customer.service.ts index 534d665..249aeca 100644 --- a/apps/hotel-management-service-server/src/customer/customer.service.ts +++ b/apps/hotel-management-service-server/src/customer/customer.service.ts @@ -1,10 +1,18 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { CustomerServiceBase } from "./base/customer.service.base"; +import { applyPaginationDefaults } from "../pagination"; +import { Prisma, Customer as PrismaCustomer } from "@prisma/client"; @Injectable() export class CustomerService extends CustomerServiceBase { constructor(protected readonly prisma: PrismaService) { super(prisma); } + + async customers( + args: T + ): Promise { + return super.customers(applyPaginationDefaults(args)); + } } diff --git a/apps/hotel-management-service-server/src/hotel/hotel.service.ts b/apps/hotel-management-service-server/src/hotel/hotel.service.ts index 723e799..6793a13 100644 --- a/apps/hotel-management-service-server/src/hotel/hotel.service.ts +++ b/apps/hotel-management-service-server/src/hotel/hotel.service.ts @@ -1,10 +1,18 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { HotelServiceBase } from "./base/hotel.service.base"; +import { applyPaginationDefaults } from "../pagination"; +import { Prisma, Hotel as PrismaHotel } from "@prisma/client"; @Injectable() export class HotelService extends HotelServiceBase { constructor(protected readonly prisma: PrismaService) { super(prisma); } + + async hotels( + args: T + ): Promise { + return super.hotels(applyPaginationDefaults(args)); + } } diff --git a/apps/hotel-management-service-server/src/pagination.ts b/apps/hotel-management-service-server/src/pagination.ts new file mode 100644 index 0000000..5883069 --- /dev/null +++ b/apps/hotel-management-service-server/src/pagination.ts @@ -0,0 +1,37 @@ +/** + * Shared pagination utilities. + */ + +export const MAX_PAGE_SIZE = 100; + +/** + * Ensures `take` and `skip` arguments are within sensible defaults. + * + * - If `take` is `undefined` or greater than `MAX_PAGE_SIZE`, it will be set to `MAX_PAGE_SIZE`. + * - If `skip` is provided and is less than `0`, it will be clamped to `0`. + * + * The function returns a shallow–copied object preserving all original properties + * while applying the sanitized `take` and `skip` values. The return type is kept + * generic so that TypeScript maintains type information from the original args. + */ +export function applyPaginationDefaults< + T extends { + take?: number; + skip?: number; + [key: string]: unknown; + } +>(args: T): T { + const sanitized: T = { + ...args, + take: + args.take === undefined || args.take === null + ? (MAX_PAGE_SIZE as unknown as T["take"]) + : (Math.min(args.take as number, MAX_PAGE_SIZE) as unknown as T["take"]), + skip: + args.skip !== undefined && (args.skip as number) < 0 + ? (0 as unknown as T["skip"]) + : args.skip, + }; + + return sanitized; +} diff --git a/apps/hotel-management-service-server/src/reservation/reservation.service.ts b/apps/hotel-management-service-server/src/reservation/reservation.service.ts index 3266fb1..5b5c9b5 100644 --- a/apps/hotel-management-service-server/src/reservation/reservation.service.ts +++ b/apps/hotel-management-service-server/src/reservation/reservation.service.ts @@ -1,10 +1,18 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { ReservationServiceBase } from "./base/reservation.service.base"; +import { applyPaginationDefaults } from "../pagination"; +import { Prisma, Reservation as PrismaReservation } from "@prisma/client"; @Injectable() export class ReservationService extends ReservationServiceBase { constructor(protected readonly prisma: PrismaService) { super(prisma); } + + async reservations( + args: T + ): Promise { + return super.reservations(applyPaginationDefaults(args)); + } } diff --git a/apps/hotel-management-service-server/src/room/room.service.ts b/apps/hotel-management-service-server/src/room/room.service.ts index d71b792..1dcd444 100644 --- a/apps/hotel-management-service-server/src/room/room.service.ts +++ b/apps/hotel-management-service-server/src/room/room.service.ts @@ -1,10 +1,18 @@ import { Injectable } from "@nestjs/common"; import { PrismaService } from "../prisma/prisma.service"; import { RoomServiceBase } from "./base/room.service.base"; +import { applyPaginationDefaults } from "../pagination"; +import { Prisma, Room as PrismaRoom } from "@prisma/client"; @Injectable() export class RoomService extends RoomServiceBase { constructor(protected readonly prisma: PrismaService) { super(prisma); } + + async rooms( + args: T + ): Promise { + return super.rooms(applyPaginationDefaults(args)); + } } diff --git a/apps/hotel-management-service-server/test/integration/pagination.e2e-spec.ts b/apps/hotel-management-service-server/test/integration/pagination.e2e-spec.ts new file mode 100644 index 0000000..eee22cf --- /dev/null +++ b/apps/hotel-management-service-server/test/integration/pagination.e2e-spec.ts @@ -0,0 +1,104 @@ +import request from "supertest"; +import { INestApplication } from "@nestjs/common"; +import { Test } from "@nestjs/testing"; +import { AppModule } from "../../../src/app.module"; +import { PrismaService } from "../../../src/prisma/prisma.service"; +import { applyPaginationDefaults, MAX_PAGE_SIZE } from "../../../src/pagination"; +import { PrismaClient } from "@prisma/client"; + +// NOTE: These e2e tests spin up the NestJS app with an in-memory SQLite DB (via Prisma) +// for isolation. Adjust the datasource provider/environment if needed. + +describe("Pagination limits (e2e)", () => { + let app: INestApplication; + let prisma: PrismaClient; + + beforeAll(async () => { + // Override DB connection to sqlite for tests + process.env.DB_URL = "file:./test.db?connection_limit=1"; + + const moduleRef = await Test.createTestingModule({ + imports: [AppModule], + }) + .overrideProvider(PrismaService) + .useValue(new PrismaService()) + .compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + + prisma = new PrismaClient({ datasources: { db: { url: process.env.DB_URL } } }); + + // Seed with > MAX_PAGE_SIZE records for each model to test limits + await seedData(MAX_PAGE_SIZE + 20); + }); + + afterAll(async () => { + await prisma.$executeRawUnsafe(`PRAGMA wal_checkpoint(FULL);`); + await prisma.$disconnect(); + await app.close(); + }); + + async function seedData(count: number) { + // Hotels + const hotelsData = Array.from({ length: count }).map((_, i) => ({ + name: `Hotel ${i}`, + address: `Street ${i}`, + description: `Desc ${i}`, + })); + + await prisma.hotel.createMany({ data: hotelsData }); + + // Customers + const customersData = Array.from({ length: count }).map((_, i) => ({ + firstName: `First${i}`, + lastName: `Last${i}`, + email: `email${i}@example.com`, + })); + await prisma.customer.createMany({ data: customersData }); + + // Rooms + const roomsData = Array.from({ length: count }).map((_, i) => ({ + floor: Math.floor(i / 10), + numberField: `${100 + i}`, + typeField: "STANDARD", + })); + await prisma.room.createMany({ data: roomsData }); + + // Reservations + const reservationData = Array.from({ length: count }).map((_, i) => ({ + checkIn: new Date(), + checkOut: new Date(), + })); + await prisma.reservation.createMany({ data: reservationData }); + } + + const endpoints = [ + { path: "/hotels", key: "hotels" }, + { path: "/customers", key: "customers" }, + { path: "/rooms", key: "rooms" }, + { path: "/reservations", key: "reservations" }, + ]; + + endpoints.forEach(({ path, key }) => { + describe(`${key} list`, () => { + it(`GET ${path} with no take`, async () => { + const res = await request(app.getHttpServer()).get(path); + expect(res.status).toBe(200); + expect(res.body.length).toBeLessThanOrEqual(MAX_PAGE_SIZE); + }); + + it(`GET ${path}?take=150 limited`, async () => { + const res = await request(app.getHttpServer()).get(path).query({ take: 150 }); + expect(res.status).toBe(200); + expect(res.body.length).toBe(MAX_PAGE_SIZE); + }); + + it(`GET ${path}?take=20 returns 20`, async () => { + const res = await request(app.getHttpServer()).get(path).query({ take: 20 }); + expect(res.status).toBe(200); + expect(res.body.length).toBe(20); + }); + }); + }); +}); diff --git a/apps/hotel-management-service-server/test/pagination.spec.ts b/apps/hotel-management-service-server/test/pagination.spec.ts new file mode 100644 index 0000000..a8eb95a --- /dev/null +++ b/apps/hotel-management-service-server/test/pagination.spec.ts @@ -0,0 +1,28 @@ +import { applyPaginationDefaults, MAX_PAGE_SIZE } from "../src/pagination"; + +describe("applyPaginationDefaults", () => { + it("should set take to MAX_PAGE_SIZE and leave skip undefined when not provided", () => { + const args = {}; + const result = applyPaginationDefaults(args); + expect(result.take).toBe(MAX_PAGE_SIZE); + expect(result.skip).toBeUndefined(); + }); + + it("should keep take unchanged when within limits", () => { + const args = { take: 20 }; + const result = applyPaginationDefaults(args); + expect(result.take).toBe(20); + }); + + it("should cap take at MAX_PAGE_SIZE when it exceeds the limit", () => { + const args = { take: MAX_PAGE_SIZE + 50 }; + const result = applyPaginationDefaults(args); + expect(result.take).toBe(MAX_PAGE_SIZE); + }); + + it("should clamp negative skip values to 0", () => { + const args = { skip: -5 }; + const result = applyPaginationDefaults(args); + expect(result.skip).toBe(0); + }); +});