Skip to content
Closed
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
44 changes: 34 additions & 10 deletions src/bounties/bounties.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { Request } from 'express';
import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { BountiesController } from './bounties.controller';
Expand All @@ -9,6 +11,7 @@ import { ClaimBountyDto } from './dto/claim-bounty.dto';
import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { IdempotencyKey } from '../common/entities/idempotency-key.entity';

describe('BountiesController', () => {
let controller: BountiesController;
Expand Down Expand Up @@ -47,7 +50,13 @@ describe('BountiesController', () => {

const module: TestingModule = await Test.createTestingModule({
controllers: [BountiesController],
providers: [{ provide: BountiesService, useValue: bountiesService }],
providers: [
{ provide: BountiesService, useValue: bountiesService },
{
provide: getRepositoryToken(IdempotencyKey),
useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() },
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
Expand All @@ -68,9 +77,11 @@ describe('BountiesController', () => {
difficulty: BountyDifficulty.INTERMEDIATE,
};

await controller.create(dto);
await controller.create(dto, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.create).toHaveBeenCalledWith(dto);
expect(bountiesService.create).toHaveBeenCalledWith(dto, 'sponsor-1');
});
});

Expand All @@ -84,15 +95,23 @@ describe('BountiesController', () => {

describe('fund', () => {
it('calls bountiesService.fund with id and funderAddress', async () => {
await controller.fund('b1', { funderAddress: 'GFUNDER' });

expect(bountiesService.fund).toHaveBeenCalledWith('b1', 'GFUNDER');
await controller.fund('b1', { funderAddress: 'GFUNDER' }, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.fund).toHaveBeenCalledWith(
'b1',
'GFUNDER',
'sponsor-1',
);
});
});

describe('claim', () => {
it('calls bountiesService.claim with id and contributorId', async () => {
await controller.claim('b1', { contributorId: 'contributor-1' });
it('calls bountiesService.claim with id and the authenticated user id', async () => {
await controller.claim('b1', {
user: { userId: 'contributor-1' },
} as unknown as Request);

expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1');
});
Expand All @@ -116,9 +135,14 @@ describe('BountiesController', () => {

describe('refund', () => {
it('calls bountiesService.refund with the route param', async () => {
await controller.refund('b1');
await controller.refund('b1', {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.refund).toHaveBeenCalledWith('b1');
expect(bountiesService.refund).toHaveBeenCalledWith(
'b1',
'sponsor-1',
);
});
});

Expand Down
16 changes: 14 additions & 2 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,13 @@ export class BountiesController {
return this.bountiesService.claim(id, userId);
}

@ApiOperation({ summary: 'Approve a claimed bounty' })
// Stub — not implemented (#253 regression). Marked deprecated so /api/docs
// doesn't present it as a working operation (#613).
@ApiOperation({
summary: 'Approve a claimed bounty',
deprecated: true,
description: 'Not implemented — stub route, no approval happens.',
})
@ApiBearerAuth()
@ApiStandardErrorResponses()
@Idempotent('bounty.approve')
Expand All @@ -143,7 +149,13 @@ export class BountiesController {
return this.bountiesService.approve(id);
}

@ApiOperation({ summary: 'Reject a claimed bounty' })
// Stub — not implemented (#253 regression). Marked deprecated so /api/docs
// doesn't present it as a working operation (#613).
@ApiOperation({
summary: 'Reject a claimed bounty',
deprecated: true,
description: 'Not implemented — stub route, no rejection happens.',
})
@ApiBearerAuth()
@ApiStandardErrorResponses()
@Idempotent('bounty.reject')
Expand Down
10 changes: 2 additions & 8 deletions src/escrow/escrow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -584,20 +584,14 @@ export class EscrowService {
recipients: Array<[string, number]>,
manager?: EntityManager,
): Promise<ContractInvocationResult> {
return this.invokeOnLockedEscrow(escrow, operation, () =>
this.soroban.invoke(
'release',
// `release(issue_id: u64, recipients)` — u64-typed on-chain (#301).
[u64(this.onChainKeyFor(escrow)), recipients],
this.contractOpts(escrow),
),
return this.invokeOnLockedEscrow(
escrow,
operation,
() =>
this.soroban.invoke(
'release',
[this.onChainKeyFor(escrow), recipients],
// `release(issue_id: u64, recipients)` — u64-typed on-chain (#301).
[u64(this.onChainKeyFor(escrow)), recipients],
this.contractOpts(escrow),
),
manager,
Expand Down
3 changes: 3 additions & 0 deletions src/github/github-webhooks.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -869,6 +869,9 @@ describe('GithubWebhooksService', () => {

expect(bountiesService.markInReview).not.toHaveBeenCalled();
expect(bountiesService.markMergedAndRelease).not.toHaveBeenCalled();
});
});

// #308: a redelivered X-GitHub-Delivery used to hit the unique constraint
// on webhook_events.deliveryId and escape handleEvent as a 500, so every
// redelivery of that event failed forever.
Expand Down
231 changes: 231 additions & 0 deletions src/milestones/bounties.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import type { Request } from 'express';
import { ArgumentMetadata, ParseUUIDPipe } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { BountiesController } from './bounties.controller';
import { BountiesService } from './bounties.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ClaimBountyDto } from './dto/claim-bounty.dto';
import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RolesGuard } from '../auth/guards/roles.guard';
import { IdempotencyKey } from '../common/entities/idempotency-key.entity';

describe('BountiesController', () => {
let controller: BountiesController;
let bountiesService: {
create: jest.Mock;
findOne: jest.Mock;
fund: jest.Mock;
claim: jest.Mock;
approve: jest.Mock;
reject: jest.Mock;
refund: jest.Mock;
list: jest.Mock;
};

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 }),
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 }),
list: jest.fn().mockResolvedValue([]),
};

const module: TestingModule = await Test.createTestingModule({
controllers: [BountiesController],
providers: [
{ provide: BountiesService, useValue: bountiesService },
{
provide: getRepositoryToken(IdempotencyKey),
useValue: { findOne: jest.fn(), save: jest.fn(), create: jest.fn() },
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.overrideGuard(RolesGuard)
.useValue({ canActivate: () => true })
.compile();

controller = module.get(BountiesController);
});

describe('create', () => {
it('calls bountiesService.create with the provided DTO', async () => {
const dto = {
issueId: '00000000-0000-0000-0000-000000000001',
sponsorId: '00000000-0000-0000-0000-000000000002',
amount: '100',
asset: AssetType.USDC,
difficulty: BountyDifficulty.INTERMEDIATE,
};

await controller.create(dto, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.create).toHaveBeenCalledWith(dto, 'sponsor-1');
});
});

describe('findOne', () => {
it('calls bountiesService.findOne with the route param', async () => {
await controller.findOne('b1');

expect(bountiesService.findOne).toHaveBeenCalledWith('b1');
});
});

describe('fund', () => {
it('calls bountiesService.fund with id and funderAddress', async () => {
await controller.fund('b1', { funderAddress: 'GFUNDER' }, {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.fund).toHaveBeenCalledWith(
'b1',
'GFUNDER',
'sponsor-1',
);
});
});

describe('claim', () => {
it('calls bountiesService.claim with id and the authenticated user id', async () => {
await controller.claim('b1', {
user: { userId: 'contributor-1' },
} as unknown as Request);

expect(bountiesService.claim).toHaveBeenCalledWith('b1', 'contributor-1');
});
});

describe('approve', () => {
it('calls bountiesService.approve with the route param', async () => {
await controller.approve('b1');

expect(bountiesService.approve).toHaveBeenCalledWith('b1');
});
});

describe('reject', () => {
it('calls bountiesService.reject with the route param', async () => {
await controller.reject('b1');

expect(bountiesService.reject).toHaveBeenCalledWith('b1');
});
});

describe('refund', () => {
it('calls bountiesService.refund with the route param', async () => {
await controller.refund('b1', {
user: { userId: 'sponsor-1' },
} as unknown as Request);

expect(bountiesService.refund).toHaveBeenCalledWith(
'b1',
'sponsor-1',
);
});
});

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

expect(bountiesService.list).toHaveBeenCalledWith({
status: BountyStatus.OPEN,
difficulty: BountyDifficulty.BEGINNER,
asset: AssetType.USDC,
repositoryId: 'repo-1',
primaryLanguage: 'TypeScript',
});
});
});

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, {
sponsorId: '00000000-0000-0000-0000-000000000001',
amount: '100',
asset: AssetType.USDC,
difficulty: BountyDifficulty.INTERMEDIATE,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});

it('rejects a body with no sponsorId', async () => {
const dto = plainToInstance(CreateBountyDto, {
issueId: '00000000-0000-0000-0000-000000000001',
amount: '100',
asset: AssetType.USDC,
difficulty: BountyDifficulty.INTERMEDIATE,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});

it('rejects a body with no amount', async () => {
const dto = plainToInstance(CreateBountyDto, {
issueId: '00000000-0000-0000-0000-000000000001',
sponsorId: '00000000-0000-0000-0000-000000000002',
asset: AssetType.USDC,
difficulty: BountyDifficulty.INTERMEDIATE,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
});
});
});
Loading