Skip to content
Merged
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
57 changes: 51 additions & 6 deletions src/bounties/bounties.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
Expand All @@ -24,13 +25,23 @@ describe('BountiesController', () => {

beforeEach(async () => {
bountiesService = {
create: jest.fn().mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }),
findOne: jest.fn().mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }),
fund: jest.fn().mockResolvedValue({ id: 'b1', status: BountyStatus.FUNDED }),
claim: jest.fn().mockResolvedValue({ id: 'b1', status: BountyStatus.CLAIMED }),
create: jest
.fn()
.mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }),
findOne: jest
.fn()
.mockResolvedValue({ id: 'b1', status: BountyStatus.OPEN }),
fund: jest
.fn()
.mockResolvedValue({ id: 'b1', status: BountyStatus.FUNDED }),
claim: jest
.fn()
.mockResolvedValue({ id: 'b1', status: BountyStatus.CLAIMED }),
approve: jest.fn().mockResolvedValue({ id: 'b1', status: 'approved' }),
reject: jest.fn().mockResolvedValue({ id: 'b1', status: 'rejected' }),
refund: jest.fn().mockResolvedValue({ id: 'b1', status: BountyStatus.REFUNDED }),
refund: jest
.fn()
.mockResolvedValue({ id: 'b1', status: BountyStatus.REFUNDED }),
list: jest.fn().mockResolvedValue([]),
};

Expand Down Expand Up @@ -113,7 +124,13 @@ describe('BountiesController', () => {

describe('list', () => {
it('calls bountiesService.list with query params', async () => {
await controller.list(BountyStatus.OPEN, BountyDifficulty.BEGINNER, AssetType.USDC, 'repo-1', 'TypeScript');
await controller.list(
BountyStatus.OPEN,
BountyDifficulty.BEGINNER,
AssetType.USDC,
'repo-1',
'TypeScript',
);

expect(bountiesService.list).toHaveBeenCalledWith({
status: BountyStatus.OPEN,
Expand All @@ -125,6 +142,34 @@ describe('BountiesController', () => {
});
});

describe('repositoryId query pipe behavior', () => {
const repositoryIdMetadata: ArgumentMetadata = {
type: 'query',
metatype: String,
data: 'repositoryId',
};
const pipe = new ParseUUIDPipe({ version: '4', optional: true });

it('accepts undefined when the query param is absent', async () => {
await expect(
pipe.transform(undefined, repositoryIdMetadata),
).resolves.toBeUndefined();
});

it('accepts a valid v4 UUID', async () => {
const validUuid = 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd';
await expect(
pipe.transform(validUuid, repositoryIdMetadata),
).resolves.toBe(validUuid);
});

it('rejects a non-UUID string with BadRequestException', async () => {
await expect(
pipe.transform('not-a-uuid', repositoryIdMetadata),
).rejects.toThrow();
});
});

describe('CreateBountyDto validation', () => {
it('rejects a body with no issueId', async () => {
const dto = plainToInstance(CreateBountyDto, {
Expand Down
18 changes: 11 additions & 7 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ import {
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import {
ApiBearerAuth,
ApiOperation,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler'; // Import the Throttle decorator
import { BountiesService } from './bounties.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
Expand All @@ -28,7 +33,8 @@ import {
ApiStandardErrorResponses,
} from '../common/swagger/api-common-responses.decorator';

class FundBountyDto {
export class FundBountyDto {
@ApiProperty({ description: 'Stellar public key of the funder' })
@IsStellarAddress()
funderAddress: string;
}
Expand Down Expand Up @@ -68,7 +74,8 @@ export class BountiesController {
difficulty?: BountyDifficulty,
@Query('asset', new ParseEnumPipe(AssetType, { optional: true }))
asset?: AssetType,
@Query('repositoryId') repositoryId?: string,
@Query('repositoryId', new ParseUUIDPipe({ version: '4', optional: true }))
repositoryId?: string,
@Query('primaryLanguage') primaryLanguage?: string,
) {
return this.bountiesService.list({
Expand Down Expand Up @@ -120,10 +127,7 @@ export class BountiesController {
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.CONTRIBUTOR)
@Post(':id/claim')
claim(
@Param('id', new ParseUUIDPipe()) id: string,
@Req() req: Request,
) {
claim(@Param('id', new ParseUUIDPipe()) id: string, @Req() req: Request) {
const userId = (req.user as any).userId;
return this.bountiesService.claim(id, userId);
}
Expand Down
29 changes: 29 additions & 0 deletions src/bounties/bounties.query.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common';

describe('BountiesController repositoryId query pipe', () => {
const repositoryIdMetadata: ArgumentMetadata = {
type: 'query',
metatype: String,
data: 'repositoryId',
};
const pipe = new ParseUUIDPipe({ version: '4', optional: true });

it('accepts undefined when repositoryId is omitted', async () => {
await expect(
pipe.transform(undefined, repositoryIdMetadata),
).resolves.toBeUndefined();
});

it('accepts a valid v4 UUID string', async () => {
const validUuid = 'c73bcdcc-2669-4bf6-81d3-e4ae73fb11fd';
await expect(pipe.transform(validUuid, repositoryIdMetadata)).resolves.toBe(
validUuid,
);
});

it('rejects a non-UUID string with BadRequestException', async () => {
await expect(
pipe.transform('not-a-uuid', repositoryIdMetadata),
).rejects.toThrow();
});
});
57 changes: 57 additions & 0 deletions src/maintenance-pool/dto/create-pool.dto.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { CreatePoolDto } from './create-pool.dto';
import { AssetType } from '../../common/enums';

describe('CreatePoolDto validation', () => {
const baseValidPayload = {
name: 'Valid Pool Name',
asset: AssetType.USDC,
};

it('accepts a valid payload and trims name whitespace', async () => {
const dto = plainToInstance(CreatePoolDto, {
...baseValidPayload,
name: ' Trimmed Pool Name ',
});
const errors = await validate(dto);
expect(errors.length).toBe(0);
expect(dto.name).toBe('Trimmed Pool Name');
});

it('rejects an empty name', async () => {
const dto = plainToInstance(CreatePoolDto, {
...baseValidPayload,
name: '',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const nameError = errors.find((e) => e.property === 'name');
expect(nameError).toBeDefined();
expect(nameError?.constraints?.isNotEmpty).toBeDefined();
});

it('rejects a whitespace-only name', async () => {
const dto = plainToInstance(CreatePoolDto, {
...baseValidPayload,
name: ' ',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const nameError = errors.find((e) => e.property === 'name');
expect(nameError).toBeDefined();
expect(nameError?.constraints?.isNotEmpty).toBeDefined();
});

it('rejects a non-string name', async () => {
const dto = plainToInstance(CreatePoolDto, {
...baseValidPayload,
name: 999,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const nameError = errors.find((e) => e.property === 'name');
expect(nameError).toBeDefined();
expect(nameError?.constraints?.isString).toBeDefined();
});
});
13 changes: 12 additions & 1 deletion src/maintenance-pool/dto/create-pool.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsOptional, IsString, IsUUID, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
import {
IsNotEmpty,
IsOptional,
IsString,
IsUUID,
MaxLength,
} from 'class-validator';
import { AssetType } from '../../common/enums';
import {
IsMoneyAmount,
Expand All @@ -8,7 +15,11 @@ import {

export class CreatePoolDto {
@ApiProperty({ maxLength: 100 })
@Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value,
)
@IsString()
@IsNotEmpty()
@MaxLength(100)
name: string;

Expand Down
27 changes: 22 additions & 5 deletions src/maintenance-pool/maintenance-pool.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,16 @@ import {
Param,
ParseUUIDPipe,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { Request } from 'express';
import {
ApiBearerAuth,
ApiOperation,
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import { Throttle } from '@nestjs/throttler';
import { IsOptional, IsUUID } from 'class-validator';
import { MaintenancePoolService } from './maintenance-pool.service';
Expand All @@ -24,24 +31,33 @@ import {
ApiStandardErrorResponses,
} from '../common/swagger/api-common-responses.decorator';

class DepositDto {
export class DepositDto {
@ApiProperty({ description: 'Amount to deposit into the maintenance pool' })
@IsMoneyAmount()
amount: string;

@ApiProperty({ description: 'Stellar public key of the funder' })
@IsStellarAddress()
funderAddress: string;
}

class AssignRewardDto {
export class AssignRewardDto {
@ApiProperty({ description: 'UUID of the issue being rewarded' })
@IsUUID()
issueId: string;

@ApiProperty({ description: 'Amount to reward from the pool' })
@IsMoneyAmount()
amount: string;

@ApiProperty({ description: 'Stellar public key of the recipient' })
@IsStellarAddress()
recipientAddress: string;

@ApiProperty({
required: false,
description: 'Optional user UUID of the recipient',
})
@IsOptional()
@IsUUID()
recipientId?: string;
Expand All @@ -59,8 +75,9 @@ export class MaintenancePoolController {
@Post()
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles(UserRole.SPONSOR, UserRole.MAINTAINER)
create(@Body() dto: CreatePoolDto) {
return this.poolService.create(dto);
create(@Body() dto: CreatePoolDto, @Req() req: Request) {
const callerUserId = (req.user as any).userId as string;
return this.poolService.create(dto, callerUserId);
}

@ApiOperation({ summary: 'List maintenance pools' })
Expand Down
15 changes: 7 additions & 8 deletions src/maintenance-pool/maintenance-pool.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,11 +69,10 @@ describe('MaintenancePoolService', () => {

describe('create', () => {
it('saves a new pool with ACTIVE status', async () => {
const pool = await service.create({
name: 'Docs pool',
asset: AssetType.USDC,
createdById: 'creator-1',
});
const pool = await service.create(
{ name: 'Docs pool', asset: AssetType.USDC, createdById: 'creator-1' },
'caller-99',
);

expect(poolRepo.save).toHaveBeenCalledWith(
expect.objectContaining({
Expand All @@ -86,11 +85,11 @@ describe('MaintenancePoolService', () => {
expect(pool.status).toBe(MaintenancePoolStatus.ACTIVE);
});

it('defaults repositoryId/createdById to null when not provided', async () => {
await service.create({ name: 'Pool', asset: AssetType.USDC });
it('falls back to callerUserId for createdById when client omits it', async () => {
await service.create({ name: 'Pool', asset: AssetType.USDC }, 'caller-99');

expect(poolRepo.save).toHaveBeenCalledWith(
expect.objectContaining({ repositoryId: null, createdById: null }),
expect.objectContaining({ repositoryId: null, createdById: 'caller-99' }),
);
});
});
Expand Down
10 changes: 7 additions & 3 deletions src/maintenance-pool/maintenance-pool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,15 @@ export class MaintenancePoolService {
private readonly escrowService: EscrowService,
) {}

async create(dto: CreatePoolDto): Promise<MaintenancePool> {
async create(
dto: CreatePoolDto,
callerUserId: string,
): Promise<MaintenancePool> {
const pool = this.poolRepo.create({
name: dto.name,
name: dto.name?.trim() ?? dto.name,
repositoryId: dto.repositoryId ?? null,
createdById: dto.createdById ?? null,
// Fall back to the authenticated caller's id when the client omits createdById.
createdById: dto.createdById ?? callerUserId,
monthlyDeposit: dto.monthlyDeposit,
asset: dto.asset,
status: MaintenancePoolStatus.ACTIVE,
Expand Down
Loading