From ec728101ff21af29344fba78a6d48e9625818bfc Mon Sep 17 00:00:00 2001 From: Errordog2 Date: Sat, 20 Jun 2026 06:03:21 +0800 Subject: [PATCH] fix: validate milestone target amounts --- src/campaigns/campaigns.service.spec.ts | 73 ++++++++++++++++++++++++ src/campaigns/campaigns.service.ts | 17 +++++- src/campaigns/dto/create-campaign.dto.ts | 14 +++-- 3 files changed, 99 insertions(+), 5 deletions(-) create mode 100644 src/campaigns/campaigns.service.spec.ts diff --git a/src/campaigns/campaigns.service.spec.ts b/src/campaigns/campaigns.service.spec.ts new file mode 100644 index 0000000..73184e8 --- /dev/null +++ b/src/campaigns/campaigns.service.spec.ts @@ -0,0 +1,73 @@ +import { BadRequestException } from '@nestjs/common'; +import { CampaignsService } from './campaigns.service'; + +describe('CampaignsService milestone target validation', () => { + const prisma = { + campaign: { + create: jest.fn(), + }, + }; + + let service: CampaignsService; + + beforeEach(() => { + jest.clearAllMocks(); + service = new CampaignsService(prisma as any, {} as any); + }); + + const baseDto = { + title: 'Orbit funding round', + goalAmount: '100', + }; + + it.each([ + ['missing', undefined], + ['zero', '0'], + ['zero decimal', '0.0000000'], + ['below the minimum precision', '0.00000001'], + ['negative', '-1'], + ['not numeric', 'abc'], + ])('rejects a %s milestone targetAmount', async (_case, targetAmount) => { + await expect( + service.createCampaign('user-1', { + ...baseDto, + milestones: [ + { + title: 'Prototype', + targetAmount, + }, + ], + }), + ).rejects.toBeInstanceOf(BadRequestException); + + expect(prisma.campaign.create).not.toHaveBeenCalled(); + }); + + it('passes a valid positive milestone targetAmount through to Prisma', async () => { + prisma.campaign.create.mockResolvedValue({ id: 'campaign-1' }); + + await service.createCampaign('user-1', { + ...baseDto, + milestones: [ + { + title: 'Prototype', + targetAmount: '0.0000001', + }, + ], + }); + + expect(prisma.campaign.create).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.objectContaining({ + milestones: { + create: [ + expect.objectContaining({ + targetAmount: '0.0000001', + }), + ], + }, + }), + }), + ); + }); +}); diff --git a/src/campaigns/campaigns.service.ts b/src/campaigns/campaigns.service.ts index 5099b39..04b14a0 100644 --- a/src/campaigns/campaigns.service.ts +++ b/src/campaigns/campaigns.service.ts @@ -16,6 +16,8 @@ import { UpdateCampaignDto } from './dto/update-campaign.dto'; import type { CreateUpdateDto } from './dto/create-update.dto'; import { ContractBalanceResponseDto } from './dto/contract-balance.dto'; +const MIN_MILESTONE_TARGET_AMOUNT = 0.0000001; + @Injectable() export class CampaignsService { constructor( @@ -36,7 +38,7 @@ export class CampaignsService { const milestoneCreates = (dto.milestones || []).map((m) => ({ title: m.title, description: m.description ?? null, - targetAmount: (m.targetAmount ?? 0) as any, + targetAmount: parseMilestoneTargetAmount(m.targetAmount), dueDate: m.dueDate ? new Date(m.dueDate) : undefined, })); @@ -446,6 +448,19 @@ export class CampaignsService { } } +function parseMilestoneTargetAmount(targetAmount?: string) { + const raw = targetAmount?.trim(); + const amount = raw ? Number(raw) : Number.NaN; + + if (!raw || !Number.isFinite(amount) || amount < MIN_MILESTONE_TARGET_AMOUNT) { + throw new BadRequestException( + `milestone targetAmount is required and must be at least ${MIN_MILESTONE_TARGET_AMOUNT}`, + ); + } + + return raw; +} + function campaignBrowseSelect() { return { id: true, diff --git a/src/campaigns/dto/create-campaign.dto.ts b/src/campaigns/dto/create-campaign.dto.ts index dfd06f0..3a40cbb 100644 --- a/src/campaigns/dto/create-campaign.dto.ts +++ b/src/campaigns/dto/create-campaign.dto.ts @@ -4,6 +4,9 @@ import { MaxLength, IsUrl, IsArray, + IsNotEmpty, + IsNumberString, + Matches, ValidateNested, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -16,10 +19,13 @@ class MilestoneInput { @IsString() description?: string; - // Accept numeric as string to be safe for Decimal columns - @IsOptional() - @IsString() - targetAmount?: string; + // Accept numeric strings to preserve precision for Decimal columns. + @IsNotEmpty() + @IsNumberString() + @Matches(/^(?=.*[1-9])\d+(?:\.\d+)?$/, { + message: 'targetAmount must be greater than 0', + }) + targetAmount: string; @IsOptional() @IsString()