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
73 changes: 73 additions & 0 deletions src/campaigns/campaigns.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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',
}),
],
},
}),
}),
);
});
});
17 changes: 16 additions & 1 deletion src/campaigns/campaigns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
}));

Expand Down Expand Up @@ -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,
Expand Down
14 changes: 10 additions & 4 deletions src/campaigns/dto/create-campaign.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import {
MaxLength,
IsUrl,
IsArray,
IsNotEmpty,
IsNumberString,
Matches,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
Expand All @@ -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()
Expand Down