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
1 change: 1 addition & 0 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ export class BountiesController {

@ApiOperation({ summary: 'Get a bounty by id' })
@ApiStandardErrorResponses()
@Throttle({ default: { limit: 120, ttl: 60000 } })
@Get(':id')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.bountiesService.findOne(id);
Expand Down
101 changes: 101 additions & 0 deletions src/common/validators/money.validator.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import {
isValidMoneyAmount,
amountToStroops,
stroopsToAmount,
isSupportedEscrowAsset,
MAX_MONETARY_AMOUNT,
} from './money.validator';
import { AssetType } from '../enums';

describe('money.validator', () => {
describe('isValidMoneyAmount', () => {
it('accepts valid positive amounts within 7 decimals', () => {
expect(isValidMoneyAmount('1')).toBe(true);
expect(isValidMoneyAmount('100.5')).toBe(true);
expect(isValidMoneyAmount('0.0000001')).toBe(true);
expect(isValidMoneyAmount('99.1234567')).toBe(true);
expect(isValidMoneyAmount(MAX_MONETARY_AMOUNT)).toBe(true);
});

it('rejects 8 decimal places', () => {
expect(isValidMoneyAmount('0.00000001')).toBe(false);
expect(isValidMoneyAmount('1.12345678')).toBe(false);
});

it('rejects 0 and non-positive numbers', () => {
expect(isValidMoneyAmount('0')).toBe(false);
expect(isValidMoneyAmount('0.0')).toBe(false);
expect(isValidMoneyAmount('0.0000000')).toBe(false);
expect(isValidMoneyAmount('-1')).toBe(false);
expect(isValidMoneyAmount('-0.5')).toBe(false);
});

it('rejects values exceeding the 100,000,000 cap', () => {
expect(isValidMoneyAmount('100000000.0000001')).toBe(false);
expect(isValidMoneyAmount('100000001')).toBe(false);
});

it('rejects numbers with illegal leading zeros', () => {
expect(isValidMoneyAmount('01')).toBe(false);
expect(isValidMoneyAmount('01.5')).toBe(false);
expect(isValidMoneyAmount('00.123')).toBe(false);
});

it('rejects non-strings and malformed numeric strings', () => {
expect(isValidMoneyAmount(123)).toBe(false);
expect(isValidMoneyAmount(null)).toBe(false);
expect(isValidMoneyAmount(undefined)).toBe(false);
expect(isValidMoneyAmount('')).toBe(false);
expect(isValidMoneyAmount('abc')).toBe(false);
expect(isValidMoneyAmount('1.2.3')).toBe(false);
});
});

describe('amountToStroops and stroopsToAmount', () => {
it('accurately converts amount to stroops and back', () => {
const amount = '123.4567000';
const stroops = amountToStroops(amount);
expect(stroops).toBe(1234567000n);

const roundTrip = stroopsToAmount(stroops);
expect(roundTrip).toBe('123.4567000');
});

it('handles minimum positive amount round-trip', () => {
const minAmount = '0.0000001';
const stroops = amountToStroops(minAmount);
expect(stroops).toBe(1n);
expect(stroopsToAmount(stroops)).toBe('0.0000001');
});

it('handles maximum amount round-trip', () => {
const stroops = amountToStroops(MAX_MONETARY_AMOUNT);
expect(stroopsToAmount(stroops)).toBe(`${MAX_MONETARY_AMOUNT}.0000000`);
});

it('amountToStroops throws on invalid money amount', () => {
expect(() => amountToStroops('0')).toThrow();
expect(() => amountToStroops('1.12345678')).toThrow();
expect(() => amountToStroops('invalid')).toThrow();
});

it('stroopsToAmount throws on negative stroops', () => {
expect(() => stroopsToAmount(-1n)).toThrow(
'Stroops amount must be non-negative',
);
});
});

describe('isSupportedEscrowAsset', () => {
it('accepts USDC and XLM', () => {
expect(isSupportedEscrowAsset(AssetType.USDC)).toBe(true);
expect(isSupportedEscrowAsset(AssetType.XLM)).toBe(true);
});

it('rejects unsupported assets', () => {
expect(isSupportedEscrowAsset('BTC')).toBe(false);
expect(isSupportedEscrowAsset('ETH')).toBe(false);
expect(isSupportedEscrowAsset(null)).toBe(false);
});
});
});
149 changes: 149 additions & 0 deletions src/github/github-webhook-payload.util.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import {
validatePullRequestPayload,
validateIssuesEventPayload,
WebhookPayloadValidationError,
} from './github-webhook-payload.util';

describe('github-webhook-payload.util', () => {
describe('validatePullRequestPayload', () => {
const validPayload = {
action: 'opened',
number: 42,
pull_request: {
html_url: 'https://github.com/org/repo/pull/42',
number: 42,
merged: false,
title: 'Fix issue',
body: 'Closes #10',
},
repository: {
id: 123456,
full_name: 'org/repo',
},
};

it('accepts a minimal valid pull request payload', () => {
const result = validatePullRequestPayload(validPayload);
expect(result.action).toBe('opened');
expect(result.number).toBe(42);
expect(result.pull_request.html_url).toBe(
'https://github.com/org/repo/pull/42',
);
expect(result.pull_request.merged).toBe(false);
expect(result.repository.id).toBe(123456);
expect(result.repository.full_name).toBe('org/repo');
});

it('throws when payload is not an object', () => {
expect(() => validatePullRequestPayload(null)).toThrow(
WebhookPayloadValidationError,
);
expect(() => validatePullRequestPayload('string')).toThrow(
WebhookPayloadValidationError,
);
});

it('throws when action is missing or wrong type', () => {
const { action, ...missingAction } = validPayload;
expect(() => validatePullRequestPayload(missingAction)).toThrow(
WebhookPayloadValidationError,
);
expect(() =>
validatePullRequestPayload({ ...validPayload, action: 123 }),
).toThrow(WebhookPayloadValidationError);
});

it('throws when number is missing or wrong type', () => {
const { number, ...missingNumber } = validPayload;
expect(() => validatePullRequestPayload(missingNumber)).toThrow(
WebhookPayloadValidationError,
);
expect(() =>
validatePullRequestPayload({ ...validPayload, number: '42' }),
).toThrow(WebhookPayloadValidationError);
});

it('throws when pull_request or its required fields are missing', () => {
expect(() =>
validatePullRequestPayload({ ...validPayload, pull_request: null }),
).toThrow(WebhookPayloadValidationError);

const invalidPr = { ...validPayload.pull_request, merged: 'not-bool' };
expect(() =>
validatePullRequestPayload({ ...validPayload, pull_request: invalidPr }),
).toThrow(WebhookPayloadValidationError);
});

it('throws when repository or repository.id is missing', () => {
expect(() =>
validatePullRequestPayload({ ...validPayload, repository: null }),
).toThrow(WebhookPayloadValidationError);

expect(() =>
validatePullRequestPayload({
...validPayload,
repository: { id: 'invalid', full_name: 'org/repo' },
}),
).toThrow(WebhookPayloadValidationError);
});
});

describe('validateIssuesEventPayload', () => {
const validIssuePayload = {
action: 'opened',
issue: {
id: 98765,
number: 10,
title: 'Bug report',
state: 'open',
updated_at: '2026-10-19T00:00:00Z',
html_url: 'https://github.com/org/repo/issues/10',
},
repository: {
id: 123456,
full_name: 'org/repo',
},
};

it('accepts a minimal valid issues event payload', () => {
const result = validateIssuesEventPayload(validIssuePayload);
expect(result.action).toBe('opened');
expect(result.issue.id).toBe(98765);
expect(result.issue.title).toBe('Bug report');
expect(result.repository.full_name).toBe('org/repo');
});

it('accepts string issue.id', () => {
const result = validateIssuesEventPayload({
...validIssuePayload,
issue: { ...validIssuePayload.issue, id: '98765' },
});
expect(result.issue.id).toBe('98765');
});

it('throws when issue.id is not a string or number', () => {
expect(() =>
validateIssuesEventPayload({
...validIssuePayload,
issue: { ...validIssuePayload.issue, id: true },
}),
).toThrow(WebhookPayloadValidationError);
});

it('throws when required fields on issue are missing or wrong type', () => {
expect(() =>
validateIssuesEventPayload({
...validIssuePayload,
issue: { ...validIssuePayload.issue, title: 123 },
}),
).toThrow(WebhookPayloadValidationError);

expect(() =>
validateIssuesEventPayload({
...validIssuePayload,
issue: { ...validIssuePayload.issue, state: null },
}),
).toThrow(WebhookPayloadValidationError);
});
});
});
2 changes: 2 additions & 0 deletions src/maintenance-pool/maintenance-pool.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,13 +81,15 @@ export class MaintenancePoolController {
}

@ApiOperation({ summary: 'List maintenance pools' })
@Throttle({ long: { limit: 1000, ttl: 3600000 } })
@Get()
list() {
return this.poolService.list();
}

@ApiOperation({ summary: 'Get a maintenance pool by id' })
@ApiStandardErrorResponses()
@Throttle({ default: { limit: 120, ttl: 60000 } })
@Get(':id')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.poolService.findOne(id);
Expand Down
9 changes: 7 additions & 2 deletions src/maintenance-pool/maintenance-pool.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,10 @@ export class MaintenancePoolService {
}

async findOne(id: string): Promise<MaintenancePool> {
const pool = await this.poolRepo.findOne({ where: { id } });
const pool = await this.poolRepo.findOne({
where: { id },
relations: { repository: true },
});
if (!pool) throw new NotFoundException(`Maintenance pool ${id} not found`);
return pool;
}
Expand Down Expand Up @@ -196,6 +199,8 @@ export class MaintenancePoolService {
}

async list(): Promise<MaintenancePool[]> {
return this.poolRepo.find();
return this.poolRepo.find({
relations: { repository: true },
});
}
}
1 change: 1 addition & 0 deletions src/milestones/milestones.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class MilestonesController {
}

@ApiOperation({ summary: 'Get a milestone by id' })
@Throttle({ default: { limit: 120, ttl: 60000 } })
@Get(':id')
findOne(@Param('id', new ParseUUIDPipe()) id: string) {
return this.milestonesService.findOne(id);
Expand Down
4 changes: 3 additions & 1 deletion src/milestones/milestones.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,9 @@ export class MilestonesService {
}

async list(): Promise<Milestone[]> {
return this.milestoneRepo.find();
return this.milestoneRepo.find({
relations: { repository: true, issues: true },
});
}

/**
Expand Down