Skip to content
Open
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
6 changes: 3 additions & 3 deletions src/bounties/bounties.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ApiTags } from '@nestjs/swagger';
import { BountiesService } from './bounties.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ClaimBountyDto } from './dto/claim-bounty.dto';
import { BountyStatus } from '../common/enums';
import { ListBountiesQueryDto } from './dto/list-bounties-query.dto';
import { Idempotent } from '../common/idempotency/idempotent.decorator';
import { IsStellarAddress } from '../common/validators/stellar-address.validator';

Expand All @@ -23,8 +23,8 @@ export class BountiesController {
}

@Get()
list(@Query('status') status?: BountyStatus) {
return this.bountiesService.list(status);
list(@Query() query?: ListBountiesQueryDto) {
return this.bountiesService.list(query);
}

@Get(':id')
Expand Down
82 changes: 81 additions & 1 deletion src/bounties/bounties.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,29 @@ import { Bounty, Team, User } from '../common/entities';
import { AssetType, BountyDifficulty, BountyStatus } from '../common/enums';
import { InvalidBountyTransitionError } from './bounty-state-machine';

function createMockQueryBuilder(result: { raw?: unknown; many?: unknown[] }) {
const qb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
innerJoin: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getRawOne: jest.fn().mockResolvedValue(result.raw),
getMany: jest.fn().mockResolvedValue(result.many ?? []),
};
return qb;
}

describe('BountiesService', () => {
let service: BountiesService;
let bountyRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock };
let bountyRepo: {
findOne: jest.Mock;
find: jest.Mock;
save: jest.Mock;
create: jest.Mock;
createQueryBuilder: jest.Mock;
};
let escrowService: {
fund: jest.Mock;
release: jest.Mock;
Expand All @@ -18,12 +38,14 @@ describe('BountiesService', () => {
beforeEach(async () => {
bountyRepo = {
findOne: jest.fn(),
find: jest.fn(),
save: jest.fn((b: Partial<Bounty>) => Promise.resolve(b)),
create: jest.fn((data: Partial<Bounty>) => ({
id: 'bounty-1',
status: BountyStatus.OPEN,
...data,
})),
createQueryBuilder: jest.fn(),
};
escrowService = {
fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }),
Expand Down Expand Up @@ -143,4 +165,62 @@ describe('BountiesService', () => {
);
expect(bounty.status).toBe(BountyStatus.PAID);
});

describe('list', () => {
it('returns all bounties when no filter is provided', async () => {
bountyRepo.find.mockResolvedValue([{ id: 'b1' }]);
const res = await service.list();
expect(bountyRepo.find).toHaveBeenCalledWith();
expect(res).toEqual([{ id: 'b1' }]);
});

it('filters by status when string enum passed', async () => {
bountyRepo.find.mockResolvedValue([{ id: 'b1', status: BountyStatus.OPEN }]);
const res = await service.list(BountyStatus.OPEN);
expect(bountyRepo.find).toHaveBeenCalledWith({
where: { status: BountyStatus.OPEN },
});
expect(res).toEqual([{ id: 'b1', status: BountyStatus.OPEN }]);
});

it('filters using query builder with difficulty, asset, language, repositoryId', async () => {
const mockResult = [{ id: 'b1' }];
const qb = createMockQueryBuilder({ many: mockResult });
bountyRepo.createQueryBuilder.mockReturnValue(qb);

const res = await service.list({
status: BountyStatus.OPEN,
difficulty: BountyDifficulty.BEGINNER,
asset: AssetType.USDC,
language: 'TypeScript',
repositoryId: 'repo-uuid-1',
});

expect(bountyRepo.createQueryBuilder).toHaveBeenCalledWith('bounty');
expect(qb.andWhere).toHaveBeenCalledWith('bounty.status = :status', {
status: BountyStatus.OPEN,
});
expect(qb.andWhere).toHaveBeenCalledWith(
'bounty.difficulty = :difficulty',
{ difficulty: BountyDifficulty.BEGINNER },
);
expect(qb.andWhere).toHaveBeenCalledWith('bounty.asset = :asset', {
asset: AssetType.USDC,
});
expect(qb.innerJoin).toHaveBeenCalledWith('bounty.issue', 'issue');
expect(qb.andWhere).toHaveBeenCalledWith(
'issue.repositoryId = :repositoryId',
{ repositoryId: 'repo-uuid-1' },
);
expect(qb.innerJoin).toHaveBeenCalledWith(
'issue.repository',
'repository',
);
expect(qb.andWhere).toHaveBeenCalledWith(
'repository.primaryLanguage = :language',
{ language: 'TypeScript' },
);
expect(res).toEqual(mockResult);
});
});
});
40 changes: 38 additions & 2 deletions src/bounties/bounties.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { BountyStatus } from '../common/enums';
import { assertTransition } from './bounty-state-machine';
import { EscrowService } from '../escrow/escrow.service';
import { CreateBountyDto } from './dto/create-bounty.dto';
import { ListBountiesQueryDto } from './dto/list-bounties-query.dto';

@Injectable()
export class BountiesService {
Expand Down Expand Up @@ -171,7 +172,42 @@ export class BountiesService {
return overdue.length;
}

async list(status?: BountyStatus): Promise<Bounty[]> {
return this.bountyRepo.find({ where: status ? { status } : {} });
async list(filter?: ListBountiesQueryDto | BountyStatus): Promise<Bounty[]> {
if (!filter) {
return this.bountyRepo.find();
}

if (typeof filter === 'string') {
return this.bountyRepo.find({ where: { status: filter } });
}

const { status, difficulty, asset, language, repositoryId } = filter;

const qb = this.bountyRepo.createQueryBuilder('bounty');

if (status) {
qb.andWhere('bounty.status = :status', { status });
}

if (difficulty) {
qb.andWhere('bounty.difficulty = :difficulty', { difficulty });
}

if (asset) {
qb.andWhere('bounty.asset = :asset', { asset });
}

if (language || repositoryId) {
qb.innerJoin('bounty.issue', 'issue');
if (repositoryId) {
qb.andWhere('issue.repositoryId = :repositoryId', { repositoryId });
}
if (language) {
qb.innerJoin('issue.repository', 'repository');
qb.andWhere('repository.primaryLanguage = :language', { language });
}
}

return qb.getMany();
}
}
30 changes: 30 additions & 0 deletions src/bounties/dto/list-bounties-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsEnum, IsOptional, IsString, IsUUID } from 'class-validator';
import { AssetType, BountyDifficulty, BountyStatus } from '../../common/enums';

export class ListBountiesQueryDto {
@ApiPropertyOptional({ enum: BountyStatus, description: 'Filter by bounty status' })
@IsOptional()
@IsEnum(BountyStatus)
status?: BountyStatus;

@ApiPropertyOptional({ enum: BountyDifficulty, description: 'Filter by bounty difficulty' })
@IsOptional()
@IsEnum(BountyDifficulty)
difficulty?: BountyDifficulty;

@ApiPropertyOptional({ enum: AssetType, description: 'Filter by asset type' })
@IsOptional()
@IsEnum(AssetType)
asset?: AssetType;

@ApiPropertyOptional({ description: 'Filter by primary programming language of repository' })
@IsOptional()
@IsString()
language?: string;

@ApiPropertyOptional({ description: 'Filter by repository ID' })
@IsOptional()
@IsUUID()
repositoryId?: string;
}
9 changes: 7 additions & 2 deletions src/sponsors/sponsors.controller.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import { Controller, Get, Param } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { SponsorsService } from './sponsors.service';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';

@ApiTags('sponsors')
@Controller('sponsors')
export class SponsorsController {
constructor(private readonly sponsorsService: SponsorsService) {}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/dashboard')
dashboard(@Param('id') id: string) {
return this.sponsorsService.dashboard(id);
}

@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Get(':id/milestones/progress')
milestoneProgress(@Param('id') id: string) {
return this.sponsorsService.milestoneProgress(id);
Expand Down
66 changes: 61 additions & 5 deletions src/sponsors/sponsors.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { SponsorsService } from './sponsors.service';
import { Bounty, Escrow, Milestone, Payment } from '../common/entities';
import { EscrowStatus, PaymentStatus } from '../common/enums';
import { EscrowStatus, MilestoneStatus, PaymentStatus } from '../common/enums';

/**
* Minimal fluent mock of TypeORM's QueryBuilder: every chainable method
Expand Down Expand Up @@ -49,6 +49,66 @@ describe('SponsorsService', () => {
service = module.get(SponsorsService);
});

describe('activeBounties', () => {
it('queries bounties by sponsorId excluding terminal statuses', async () => {
const mockBounties = [{ id: 'b1', sponsorId: 'sponsor-1' }];
const qb = createMockQueryBuilder({ many: mockBounties });
bountyRepo.createQueryBuilder.mockReturnValue(qb);

const res = await service.activeBounties('sponsor-1');

expect(bountyRepo.createQueryBuilder).toHaveBeenCalledWith('bounty');
expect(qb.where).toHaveBeenCalledWith('bounty.sponsorId = :sponsorId', {
sponsorId: 'sponsor-1',
});
expect(qb.andWhere).toHaveBeenCalledWith(
'bounty.status NOT IN (:...terminal)',
{
terminal: ['paid', 'refunded', 'expired'],
},
);
expect(res).toEqual(mockBounties);
});
});

describe('activeMilestones', () => {
it('finds milestones with FUNDED or IN_PROGRESS status for sponsor', async () => {
const mockMilestones = [
{ id: 'm1', sponsorId: 'sponsor-1', status: MilestoneStatus.FUNDED },
];
milestoneRepo.find.mockResolvedValue(mockMilestones);

const res = await service.activeMilestones('sponsor-1');

expect(milestoneRepo.find).toHaveBeenCalledWith({
where: [
{ sponsorId: 'sponsor-1', status: MilestoneStatus.FUNDED },
{ sponsorId: 'sponsor-1', status: MilestoneStatus.IN_PROGRESS },
],
});
expect(res).toEqual(mockMilestones);
});
});

describe('milestoneProgress', () => {
it('calculates progress ratio and guards against division by zero', async () => {
milestoneRepo.find.mockResolvedValue([
{ id: 'm1', title: 'M1', budget: '1000', distributed: '500' },
{ id: 'm2', title: 'M2', budget: '0', distributed: '0' },
]);

const progress = await service.milestoneProgress('sponsor-1');

expect(milestoneRepo.find).toHaveBeenCalledWith({
where: { sponsorId: 'sponsor-1' },
});
expect(progress).toEqual([
{ milestoneId: 'm1', title: 'M1', progress: 0.5 },
{ milestoneId: 'm2', title: 'M2', progress: 0 },
]);
});
});

describe('budgetLocked', () => {
it('sums Escrow.amount directly, filtered by sponsorId and LOCKED status', async () => {
const qb = createMockQueryBuilder({ raw: { total: '1250.5000000' } });
Expand Down Expand Up @@ -122,10 +182,6 @@ describe('SponsorsService', () => {

await service.dashboard('sponsor-1');

// recentPayments's join must key off escrow.sponsorId so it still
// finds payments after the parent bounty/milestone is deleted, and so
// milestone-funded payments (which never had a `bounty` at all) show
// up too — see #27.
expect(paymentsQb.innerJoin).toHaveBeenCalledWith(
'payment.escrow',
'escrow',
Expand Down
8 changes: 4 additions & 4 deletions test/users.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ describe('UsersController (e2e)', () => {
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => false }) // Simulate unauthenticated
.useValue({ canActivate: () => false }) // Simulate denied access
.compile();

app = moduleFixture.createNestApplication();
Expand All @@ -33,15 +33,15 @@ describe('UsersController (e2e)', () => {
});

describe('GET /users', () => {
it('should reject unauthenticated requests with 401', () => {
it('should reject unauthenticated requests with 403 when guard denies access', () => {
return request(app.getHttpServer())
.get('/users')
.expect(403); // Assuming the guard returns 403 when not authorized
.expect(403);
});
});

describe('GET /users/:id', () => {
it('should reject unauthenticated requests with 401', () => {
it('should reject unauthenticated requests with 403 when guard denies access', () => {
return request(app.getHttpServer())
.get('/users/00000000-0000-0000-0000-000000000000')
.expect(403);
Expand Down