Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/bounties/bounties-pagination.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { BountiesService } from './bounties.service';
import { EscrowService } from '../escrow/escrow.service';
import { Bounty, Team, User } from '../common/entities';
import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums';
import { DEFAULT_PAGE_LIMIT, MAX_PAGE_LIMIT } from '../common/dto/pagination.dto';

describe('BountiesService list pagination', () => {
let service: BountiesService;
let bountyRepo: { findAndCount: jest.Mock };

beforeEach(async () => {
bountyRepo = {
findAndCount: jest.fn(),
};

const module: TestingModule = await Test.createTestingModule({
providers: [
BountiesService,
{ provide: getRepositoryToken(Bounty), useValue: bountyRepo },
{ provide: getRepositoryToken(User), useValue: {} },
{ provide: getRepositoryToken(Team), useValue: {} },
{ provide: EscrowService, useValue: {} },
],
}).compile();

service = module.get(BountiesService);
});

it('defaults limit to DEFAULT_PAGE_LIMIT (20) and offset to 0', async () => {
const fakeBounties = Array.from({ length: 20 }, (_, i) => ({
id: `bounty-${i}`,
status: BountyStatus.OPEN,
})) as Bounty[];

bountyRepo.findAndCount.mockResolvedValue([fakeBounties, 45]);

const result = await service.list();

expect(bountyRepo.findAndCount).toHaveBeenCalledWith({
where: {},
take: DEFAULT_PAGE_LIMIT,
skip: 0,
order: { createdAt: 'DESC' },
});

expect(result.data.length).toBe(20);
expect(result.total).toBe(45);
expect(result.limit).toBe(20);
expect(result.offset).toBe(0);
expect(result.hasMore).toBe(true);
});

it('enforces MAX_PAGE_LIMIT when caller passes a higher limit', async () => {
bountyRepo.findAndCount.mockResolvedValue([[], 0]);

await service.list({ limit: 5000, offset: 10 });

expect(bountyRepo.findAndCount).toHaveBeenCalledWith({
where: {},
take: MAX_PAGE_LIMIT,
skip: 10,
order: { createdAt: 'DESC' },
});
});

it('filters by status when provided in query', async () => {
bountyRepo.findAndCount.mockResolvedValue([[], 0]);

await service.list({ status: BountyStatus.FUNDED, limit: 10, offset: 5 });

expect(bountyRepo.findAndCount).toHaveBeenCalledWith({
where: { status: BountyStatus.FUNDED },
take: 10,
skip: 5,
order: { createdAt: 'DESC' },
});
});

it('correctly calculates hasMore = false when at end of list', async () => {
const fakeBounties = Array.from({ length: 5 }, (_, i) => ({
id: `bounty-${i}`,
status: BountyStatus.OPEN,
})) as Bounty[];

bountyRepo.findAndCount.mockResolvedValue([fakeBounties, 25]);

const result = await service.list({ limit: 20, offset: 20 });

expect(result.hasMore).toBe(false);
expect(result.total).toBe(25);
});
});
8 changes: 5 additions & 3 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ 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 { ListBountiesDto } from './dto/list-bounties.dto';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { PaginatedResponse } from '../common/dto/pagination.dto';
import { Bounty } from '../common/entities';

class FundBountyDto {
@IsString()
Expand All @@ -23,8 +25,8 @@ export class BountiesController {
}

@Get()
list(@Query('status') status?: BountyStatus) {
return this.bountiesService.list(status);
list(@Query() query: ListBountiesDto): Promise<PaginatedResponse<Bounty>> {
return this.bountiesService.list(query);
}

@Get(':id')
Expand Down
29 changes: 26 additions & 3 deletions src/bounties/bounties.service.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { FindOptionsWhere, Repository } from 'typeorm';
import { Bounty, Team, User } from '../common/entities';
import { BountyStatus } from '../common/enums';
import { assertTransition } from './bounty-state-machine';
import { EscrowService } from '../escrow/escrow.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ListBountiesDto } from './dto/list-bounties.dto';
import {
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
PaginatedResponse,
buildPaginatedResponse,
} from '../common/dto/pagination.dto';

@Injectable()
export class BountiesService {
Expand Down Expand Up @@ -171,7 +178,23 @@ export class BountiesService {
return overdue.length;
}

async list(status?: BountyStatus): Promise<Bounty[]> {
return this.bountyRepo.find({ where: status ? { status } : {} });
async list(query?: ListBountiesDto): Promise<PaginatedResponse<Bounty>> {
const limit = Math.min(
Math.max(Number(query?.limit) || DEFAULT_PAGE_LIMIT, 1),
MAX_PAGE_LIMIT,
);
const offset = Math.max(Number(query?.offset) || 0, 0);
const where: FindOptionsWhere<Bounty> = query?.status
? { status: query.status }
: {};

const [data, total] = await this.bountyRepo.findAndCount({
where,
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
});

return buildPaginatedResponse(data, total, limit, offset);
}
}
11 changes: 11 additions & 0 deletions src/bounties/dto/list-bounties.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import { BountyStatus } from '../../common/enums';
import { PaginationQueryDto } from '../../common/dto/pagination.dto';

export class ListBountiesDto extends PaginationQueryDto {
@ApiPropertyOptional({ enum: BountyStatus, description: 'Filter bounties by status' })
@IsOptional()
@IsEnum(BountyStatus)
status?: BountyStatus;
}
55 changes: 55 additions & 0 deletions src/common/dto/pagination.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';

export const DEFAULT_PAGE_LIMIT = 20;
export const MAX_PAGE_LIMIT = 100;

export class PaginationQueryDto {
@ApiPropertyOptional({
description: `Number of records to return (default: ${DEFAULT_PAGE_LIMIT}, max: ${MAX_PAGE_LIMIT})`,
default: DEFAULT_PAGE_LIMIT,
minimum: 1,
maximum: MAX_PAGE_LIMIT,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_PAGE_LIMIT)
limit: number = DEFAULT_PAGE_LIMIT;

@ApiPropertyOptional({
description: 'Number of records to skip (default: 0)',
default: 0,
minimum: 0,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset: number = 0;
}

export interface PaginatedResponse<T> {
data: T[];
total: number;
limit: number;
offset: number;
hasMore: boolean;
}

export function buildPaginatedResponse<T>(
data: T[],
total: number,
limit: number = DEFAULT_PAGE_LIMIT,
offset: number = 0,
): PaginatedResponse<T> {
return {
data,
total,
limit,
offset,
hasMore: offset + data.length < total,
};
}
8 changes: 5 additions & 3 deletions src/maintenance-pool/maintenance-pool.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } 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, PaginatedResponse } from '../common/dto/pagination.dto';
import { MaintenancePool } from '../common/entities';

class DepositDto {
@IsMoneyAmount()
Expand Down Expand Up @@ -37,8 +39,8 @@ export class MaintenancePoolController {
}

@Get()
list() {
return this.poolService.list();
list(@Query() query: PaginationQueryDto): Promise<PaginatedResponse<MaintenancePool>> {
return this.poolService.list(query);
}

@Get(':id')
Expand Down
25 changes: 23 additions & 2 deletions src/maintenance-pool/maintenance-pool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { MaintenancePool } from '../common/entities';
import { MaintenancePoolStatus } from '../common/enums';
import { EscrowService } from '../escrow/escrow.service';
import { CreatePoolDto } from './dto/create-pool.dto';
import {
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
PaginationQueryDto,
PaginatedResponse,
buildPaginatedResponse,
} from '../common/dto/pagination.dto';

/**
* Recurring maintenance pool: sponsors make monthly deposits into a shared
Expand Down Expand Up @@ -106,7 +113,21 @@ export class MaintenancePoolService {
return payment;
}

async list(): Promise<MaintenancePool[]> {
return this.poolRepo.find();
async list(
query?: PaginationQueryDto,
): Promise<PaginatedResponse<MaintenancePool>> {
const limit = Math.min(
Math.max(Number(query?.limit) || DEFAULT_PAGE_LIMIT, 1),
MAX_PAGE_LIMIT,
);
const offset = Math.max(Number(query?.offset) || 0, 0);

const [data, total] = await this.poolRepo.findAndCount({
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
});

return buildPaginatedResponse(data, total, limit, offset);
}
}
8 changes: 5 additions & 3 deletions src/milestones/milestones.controller.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } 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, PaginatedResponse } from '../common/dto/pagination.dto';
import { Milestone } from '../common/entities';

class FundMilestoneDto {
@IsString()
Expand All @@ -30,8 +32,8 @@ export class MilestonesController {
}

@Get()
list() {
return this.milestonesService.list();
list(@Query() query: PaginationQueryDto): Promise<PaginatedResponse<Milestone>> {
return this.milestonesService.list(query);
}

@Get(':id')
Expand Down
23 changes: 21 additions & 2 deletions src/milestones/milestones.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@ import { Issue, Milestone } from '../common/entities';
import { MilestoneStatus } from '../common/enums';
import { EscrowService } from '../escrow/escrow.service';
import { CreateMilestoneDto } from './dto/create-milestone.dto';
import {
DEFAULT_PAGE_LIMIT,
MAX_PAGE_LIMIT,
PaginationQueryDto,
PaginatedResponse,
buildPaginatedResponse,
} from '../common/dto/pagination.dto';

@Injectable()
export class MilestonesService {
Expand Down Expand Up @@ -128,7 +135,19 @@ export class MilestonesService {
return payment;
}

async list(): Promise<Milestone[]> {
return this.milestoneRepo.find();
async list(query?: PaginationQueryDto): Promise<PaginatedResponse<Milestone>> {
const limit = Math.min(
Math.max(Number(query?.limit) || DEFAULT_PAGE_LIMIT, 1),
MAX_PAGE_LIMIT,
);
const offset = Math.max(Number(query?.offset) || 0, 0);

const [data, total] = await this.milestoneRepo.findAndCount({
take: limit,
skip: offset,
order: { createdAt: 'DESC' },
});

return buildPaginatedResponse(data, total, limit, offset);
}
}
Loading