diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index 173df3d..41ad54a 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -1,11 +1,14 @@ import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { ApiTags, ApiQuery } from '@nestjs/swagger'; import { IsString } from 'class-validator'; import { BountiesService } from './bounties.service'; import { CreateBountyDto } from './dto/create-bounty.dto'; import { ClaimBountyDto } from './dto/claim-bounty.dto'; import { BountyStatus } from '../common/enums'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; +import { Bounty } from '../common/entities'; class FundBountyDto { @IsString() @@ -23,8 +26,17 @@ export class BountiesController { } @Get() - list(@Query('status') status?: BountyStatus) { - return this.bountiesService.list(status); + @ApiQuery({ name: 'status', required: false, enum: BountyStatus }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async list( + @Query('status') status?: BountyStatus, + @Query() paginationQuery?: PaginationQueryDto, + ): Promise> { + const page = paginationQuery?.page || 1; + const limit = paginationQuery?.limit || 50; + const { data, total } = await this.bountiesService.list(status, page, limit); + return new PaginatedResponseDto(data, page, limit, total); } @Get(':id') diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index 7bf75f8..5b88987 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -171,7 +171,21 @@ export class BountiesService { return overdue.length; } - async list(status?: BountyStatus): Promise { - return this.bountyRepo.find({ where: status ? { status } : {} }); + async list( + status?: BountyStatus, + page: number = 1, + limit: number = 50, + ): Promise<{ data: Bounty[]; total: number }> { + const skip = (page - 1) * limit; + const where = status ? { status } : {}; + + const [data, total] = await this.bountyRepo.findAndCount({ + where, + take: limit, + skip, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; } } diff --git a/src/common/dto/paginated-response.dto.ts b/src/common/dto/paginated-response.dto.ts new file mode 100644 index 0000000..7a23e35 --- /dev/null +++ b/src/common/dto/paginated-response.dto.ts @@ -0,0 +1,54 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * Metadata for paginated responses, allowing clients to know if + * more pages exist and how to request them. + */ +export class PaginationMetadata { + @ApiProperty({ description: 'Current page number (1-indexed)' }) + page: number; + + @ApiProperty({ description: 'Number of items per page' }) + limit: number; + + @ApiProperty({ description: 'Total number of items across all pages' }) + totalItems: number; + + @ApiProperty({ description: 'Total number of pages' }) + totalPages: number; + + @ApiProperty({ description: 'Whether there is a next page' }) + hasNextPage: boolean; + + @ApiProperty({ description: 'Whether there is a previous page' }) + hasPreviousPage: boolean; +} + +/** + * Standard paginated response wrapper for list endpoints. + */ +export class PaginatedResponseDto { + @ApiProperty({ description: 'Array of items for the current page' }) + data: T[]; + + @ApiProperty({ description: 'Pagination metadata', type: PaginationMetadata }) + meta: PaginationMetadata; + + constructor( + data: T[], + page: number, + limit: number, + totalItems: number, + ) { + this.data = data; + const totalPages = Math.ceil(totalItems / limit); + this.meta = { + page, + limit, + totalItems, + totalPages, + hasNextPage: page < totalPages, + hasPreviousPage: page > 1, + }; + } +} diff --git a/src/common/dto/pagination-query.dto.ts b/src/common/dto/pagination-query.dto.ts new file mode 100644 index 0000000..ff47061 --- /dev/null +++ b/src/common/dto/pagination-query.dto.ts @@ -0,0 +1,33 @@ +import { IsInt, IsOptional, Min, Max } from 'class-validator'; +import { Type } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; + +/** + * Standard pagination query parameters for list endpoints. + * Enforces a maximum page size to prevent unbounded responses. + */ +export class PaginationQueryDto { + @ApiPropertyOptional({ + description: 'Page number (1-indexed)', + default: 1, + minimum: 1, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number = 1; + + @ApiPropertyOptional({ + description: 'Number of items per page', + default: 50, + minimum: 1, + maximum: 100, + }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit?: number = 50; +} diff --git a/src/maintenance-pool/maintenance-pool.controller.ts b/src/maintenance-pool/maintenance-pool.controller.ts index 3582585..9acde8d 100644 --- a/src/maintenance-pool/maintenance-pool.controller.ts +++ b/src/maintenance-pool/maintenance-pool.controller.ts @@ -1,10 +1,13 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiTags, ApiQuery } from '@nestjs/swagger'; import { IsOptional, IsString, IsUUID } from 'class-validator'; import { MaintenancePoolService } from './maintenance-pool.service'; import { CreatePoolDto } from './dto/create-pool.dto'; import { IsMoneyAmount } from '../common/validators/money.validator'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; +import { MaintenancePool } from '../common/entities'; class DepositDto { @IsMoneyAmount() @@ -37,8 +40,15 @@ export class MaintenancePoolController { } @Get() - list() { - return this.poolService.list(); + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async list( + @Query() paginationQuery?: PaginationQueryDto, + ): Promise> { + const page = paginationQuery?.page || 1; + const limit = paginationQuery?.limit || 50; + const { data, total } = await this.poolService.list(page, limit); + return new PaginatedResponseDto(data, page, limit, total); } @Get(':id') diff --git a/src/maintenance-pool/maintenance-pool.service.ts b/src/maintenance-pool/maintenance-pool.service.ts index 258603a..c645c10 100644 --- a/src/maintenance-pool/maintenance-pool.service.ts +++ b/src/maintenance-pool/maintenance-pool.service.ts @@ -106,7 +106,18 @@ export class MaintenancePoolService { return payment; } - async list(): Promise { - return this.poolRepo.find(); + async list( + page: number = 1, + limit: number = 50, + ): Promise<{ data: MaintenancePool[]; total: number }> { + const skip = (page - 1) * limit; + + const [data, total] = await this.poolRepo.findAndCount({ + take: limit, + skip, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; } } diff --git a/src/milestones/milestones.controller.ts b/src/milestones/milestones.controller.ts index 0da2760..033d81b 100644 --- a/src/milestones/milestones.controller.ts +++ b/src/milestones/milestones.controller.ts @@ -1,9 +1,12 @@ -import { Body, Controller, Get, Param, Post } from '@nestjs/common'; -import { ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common'; +import { ApiTags, ApiQuery } from '@nestjs/swagger'; import { IsOptional, IsString, IsUUID } from 'class-validator'; import { MilestonesService } from './milestones.service'; import { CreateMilestoneDto } from './dto/create-milestone.dto'; import { Idempotent } from '../common/idempotency/idempotent.decorator'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; +import { Milestone } from '../common/entities'; class FundMilestoneDto { @IsString() @@ -30,8 +33,15 @@ export class MilestonesController { } @Get() - list() { - return this.milestonesService.list(); + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async list( + @Query() paginationQuery?: PaginationQueryDto, + ): Promise> { + const page = paginationQuery?.page || 1; + const limit = paginationQuery?.limit || 50; + const { data, total } = await this.milestonesService.list(page, limit); + return new PaginatedResponseDto(data, page, limit, total); } @Get(':id') diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 43bb7fb..2e802e3 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -128,7 +128,18 @@ export class MilestonesService { return payment; } - async list(): Promise { - return this.milestoneRepo.find(); + async list( + page: number = 1, + limit: number = 50, + ): Promise<{ data: Milestone[]; total: number }> { + const skip = (page - 1) * limit; + + const [data, total] = await this.milestoneRepo.findAndCount({ + take: limit, + skip, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; } } diff --git a/src/users/users.controller.ts b/src/users/users.controller.ts index 37a22bb..cccfa5b 100644 --- a/src/users/users.controller.ts +++ b/src/users/users.controller.ts @@ -1,8 +1,11 @@ -import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Body, Controller, Get, Param, Patch, Query, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags, ApiQuery } from '@nestjs/swagger'; import { IsString } from 'class-validator'; import { UsersService } from './users.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; +import { PaginationQueryDto } from '../common/dto/pagination-query.dto'; +import { PaginatedResponseDto } from '../common/dto/paginated-response.dto'; +import { User } from '../common/entities'; class SetStellarAddressDto { @IsString() @@ -15,8 +18,15 @@ export class UsersController { constructor(private readonly usersService: UsersService) {} @Get() - list() { - return this.usersService.list(); + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + async list( + @Query() paginationQuery?: PaginationQueryDto, + ): Promise> { + const page = paginationQuery?.page || 1; + const limit = paginationQuery?.limit || 50; + const { data, total } = await this.usersService.list(page, limit); + return new PaginatedResponseDto(data, page, limit, total); } @Get(':id') diff --git a/src/users/users.service.ts b/src/users/users.service.ts index faf55c7..a2e9cbc 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -98,7 +98,18 @@ export class UsersService { return this.userRepo.save(user); } - async list(): Promise { - return this.userRepo.find(); + async list( + page: number = 1, + limit: number = 50, + ): Promise<{ data: User[]; total: number }> { + const skip = (page - 1) * limit; + + const [data, total] = await this.userRepo.findAndCount({ + take: limit, + skip, + order: { createdAt: 'DESC' }, + }); + + return { data, total }; } } diff --git a/test/pagination.e2e-spec.ts b/test/pagination.e2e-spec.ts new file mode 100644 index 0000000..efa80d0 --- /dev/null +++ b/test/pagination.e2e-spec.ts @@ -0,0 +1,293 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import * as request from 'supertest'; +import { AppModule } from '../src/app.module'; +import { DataSource } from 'typeorm'; +import { Bounty, Milestone, MaintenancePool, User } from '../src/common/entities'; +import { + BountyStatus, + MilestoneStatus, + MaintenancePoolStatus, + UserRole, + AssetType, +} from '../src/common/enums'; + +describe('Pagination (e2e)', () => { + let app: INestApplication; + let dataSource: DataSource; + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [AppModule], + }).compile(); + + app = moduleFixture.createNestApplication(); + app.useGlobalPipes(new ValidationPipe({ transform: true })); + await app.init(); + + dataSource = moduleFixture.get(DataSource); + }); + + afterAll(async () => { + await app.close(); + }); + + beforeEach(async () => { + // Clean up tables before each test + await dataSource.query('DELETE FROM bounty'); + await dataSource.query('DELETE FROM milestone'); + await dataSource.query('DELETE FROM maintenance_pool'); + await dataSource.query('DELETE FROM "user"'); + }); + + describe('GET /bounties', () => { + it('should return paginated bounties with default page size', async () => { + // Seed 75 bounties (more than default page size of 50) + const bountyRepo = dataSource.getRepository(Bounty); + const bounties: Partial[] = []; + for (let i = 0; i < 75; i++) { + bounties.push({ + amount: '10.0000000', + asset: AssetType.USDC, + status: BountyStatus.OPEN, + }); + } + await bountyRepo.save(bounties); + + const res = await request(app.getHttpServer()) + .get('/bounties') + .expect(200); + + expect(res.body).toHaveProperty('data'); + expect(res.body).toHaveProperty('meta'); + expect(res.body.data).toHaveLength(50); // Default page size + expect(res.body.meta.totalItems).toBe(75); + expect(res.body.meta.totalPages).toBe(2); + expect(res.body.meta.page).toBe(1); + expect(res.body.meta.limit).toBe(50); + expect(res.body.meta.hasNextPage).toBe(true); + expect(res.body.meta.hasPreviousPage).toBe(false); + }); + + it('should return second page of bounties', async () => { + const bountyRepo = dataSource.getRepository(Bounty); + const bounties: Partial[] = []; + for (let i = 0; i < 75; i++) { + bounties.push({ + amount: '10.0000000', + asset: AssetType.USDC, + status: BountyStatus.OPEN, + }); + } + await bountyRepo.save(bounties); + + const res = await request(app.getHttpServer()) + .get('/bounties?page=2&limit=50') + .expect(200); + + expect(res.body.data).toHaveLength(25); // Remaining items + expect(res.body.meta.page).toBe(2); + expect(res.body.meta.hasNextPage).toBe(false); + expect(res.body.meta.hasPreviousPage).toBe(true); + }); + + it('should enforce maximum page size of 100', async () => { + const bountyRepo = dataSource.getRepository(Bounty); + const bounties: Partial[] = []; + for (let i = 0; i < 150; i++) { + bounties.push({ + amount: '10.0000000', + asset: AssetType.USDC, + status: BountyStatus.OPEN, + }); + } + await bountyRepo.save(bounties); + + // Try to request more than max (should fail validation) + await request(app.getHttpServer()) + .get('/bounties?limit=150') + .expect(400); + + // Request exactly max (should succeed) + const res = await request(app.getHttpServer()) + .get('/bounties?limit=100') + .expect(200); + + expect(res.body.data).toHaveLength(100); + expect(res.body.meta.limit).toBe(100); + }); + + it('should filter by status and paginate', async () => { + const bountyRepo = dataSource.getRepository(Bounty); + const bounties: Partial[] = []; + for (let i = 0; i < 60; i++) { + bounties.push({ + amount: '10.0000000', + asset: AssetType.USDC, + status: i < 30 ? BountyStatus.OPEN : BountyStatus.FUNDED, + }); + } + await bountyRepo.save(bounties); + + const res = await request(app.getHttpServer()) + .get('/bounties?status=open&limit=20') + .expect(200); + + expect(res.body.data).toHaveLength(20); + expect(res.body.meta.totalItems).toBe(30); // Only OPEN bounties + expect(res.body.data.every((b: Bounty) => b.status === BountyStatus.OPEN)).toBe(true); + }); + + it('should return empty data for page beyond total', async () => { + const bountyRepo = dataSource.getRepository(Bounty); + await bountyRepo.save({ + amount: '10.0000000', + asset: AssetType.USDC, + status: BountyStatus.OPEN, + }); + + const res = await request(app.getHttpServer()) + .get('/bounties?page=10') + .expect(200); + + expect(res.body.data).toHaveLength(0); + expect(res.body.meta.totalItems).toBe(1); + expect(res.body.meta.hasNextPage).toBe(false); + }); + }); + + describe('GET /milestones', () => { + it('should return paginated milestones with default page size', async () => { + const milestoneRepo = dataSource.getRepository(Milestone); + const milestones: Partial[] = []; + for (let i = 0; i < 75; i++) { + milestones.push({ + title: `Milestone ${i}`, + budget: '1000.0000000', + asset: AssetType.USDC, + status: MilestoneStatus.OPEN, + }); + } + await milestoneRepo.save(milestones); + + const res = await request(app.getHttpServer()) + .get('/milestones') + .expect(200); + + expect(res.body.data).toHaveLength(50); + expect(res.body.meta.totalItems).toBe(75); + expect(res.body.meta.hasNextPage).toBe(true); + }); + + it('should respect custom page size', async () => { + const milestoneRepo = dataSource.getRepository(Milestone); + const milestones: Partial[] = []; + for (let i = 0; i < 30; i++) { + milestones.push({ + title: `Milestone ${i}`, + budget: '1000.0000000', + asset: AssetType.USDC, + status: MilestoneStatus.OPEN, + }); + } + await milestoneRepo.save(milestones); + + const res = await request(app.getHttpServer()) + .get('/milestones?limit=10') + .expect(200); + + expect(res.body.data).toHaveLength(10); + expect(res.body.meta.limit).toBe(10); + expect(res.body.meta.totalPages).toBe(3); + }); + }); + + describe('GET /maintenance-pools', () => { + it('should return paginated maintenance pools', async () => { + const poolRepo = dataSource.getRepository(MaintenancePool); + const pools: Partial[] = []; + for (let i = 0; i < 75; i++) { + pools.push({ + name: `Pool ${i}`, + asset: AssetType.USDC, + status: MaintenancePoolStatus.ACTIVE, + }); + } + await poolRepo.save(pools); + + const res = await request(app.getHttpServer()) + .get('/maintenance-pools') + .expect(200); + + expect(res.body.data).toHaveLength(50); + expect(res.body.meta.totalItems).toBe(75); + expect(res.body.meta.hasNextPage).toBe(true); + }); + + it('should navigate through pages correctly', async () => { + const poolRepo = dataSource.getRepository(MaintenancePool); + const pools: Partial[] = []; + for (let i = 0; i < 25; i++) { + pools.push({ + name: `Pool ${i}`, + asset: AssetType.USDC, + status: MaintenancePoolStatus.ACTIVE, + }); + } + await poolRepo.save(pools); + + const page1 = await request(app.getHttpServer()) + .get('/maintenance-pools?limit=10&page=1') + .expect(200); + + const page2 = await request(app.getHttpServer()) + .get('/maintenance-pools?limit=10&page=2') + .expect(200); + + expect(page1.body.data).toHaveLength(10); + expect(page2.body.data).toHaveLength(10); + expect(page1.body.data[0].id).not.toBe(page2.body.data[0].id); // Different items + expect(page2.body.meta.hasPreviousPage).toBe(true); + }); + }); + + describe('GET /users', () => { + it('should return paginated users', async () => { + const userRepo = dataSource.getRepository(User); + const users: Partial[] = []; + for (let i = 0; i < 75; i++) { + users.push({ + username: `user${i}`, + email: `user${i}@example.com`, + roles: [UserRole.CONTRIBUTOR], + }); + } + await userRepo.save(users); + + const res = await request(app.getHttpServer()) + .get('/users') + .expect(200); + + expect(res.body.data).toHaveLength(50); + expect(res.body.meta.totalItems).toBe(75); + expect(res.body.meta.hasNextPage).toBe(true); + }); + + it('should handle invalid pagination parameters', async () => { + // Invalid page (less than 1) + await request(app.getHttpServer()) + .get('/users?page=0') + .expect(400); + + // Invalid limit (less than 1) + await request(app.getHttpServer()) + .get('/users?limit=0') + .expect(400); + + // Invalid limit (greater than max) + await request(app.getHttpServer()) + .get('/users?limit=101') + .expect(400); + }); + }); +});