diff --git a/src/bounties/bounties.controller.ts b/src/bounties/bounties.controller.ts index d2e6056..fa4ad4a 100644 --- a/src/bounties/bounties.controller.ts +++ b/src/bounties/bounties.controller.ts @@ -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'; @@ -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') diff --git a/src/bounties/bounties.service.spec.ts b/src/bounties/bounties.service.spec.ts index fd5a497..0d03de4 100644 --- a/src/bounties/bounties.service.spec.ts +++ b/src/bounties/bounties.service.spec.ts @@ -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; @@ -18,12 +38,14 @@ describe('BountiesService', () => { beforeEach(async () => { bountyRepo = { findOne: jest.fn(), + find: jest.fn(), save: jest.fn((b: Partial) => Promise.resolve(b)), create: jest.fn((data: Partial) => ({ id: 'bounty-1', status: BountyStatus.OPEN, ...data, })), + createQueryBuilder: jest.fn(), }; escrowService = { fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }), @@ -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); + }); + }); }); diff --git a/src/bounties/bounties.service.ts b/src/bounties/bounties.service.ts index 7bf75f8..b7110fd 100644 --- a/src/bounties/bounties.service.ts +++ b/src/bounties/bounties.service.ts @@ -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 { @@ -171,7 +172,42 @@ export class BountiesService { return overdue.length; } - async list(status?: BountyStatus): Promise { - return this.bountyRepo.find({ where: status ? { status } : {} }); + async list(filter?: ListBountiesQueryDto | BountyStatus): Promise { + 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(); } } diff --git a/src/bounties/dto/list-bounties-query.dto.ts b/src/bounties/dto/list-bounties-query.dto.ts new file mode 100644 index 0000000..9a24cce --- /dev/null +++ b/src/bounties/dto/list-bounties-query.dto.ts @@ -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; +} diff --git a/src/sponsors/sponsors.controller.ts b/src/sponsors/sponsors.controller.ts index 48e7cb7..ba71e58 100644 --- a/src/sponsors/sponsors.controller.ts +++ b/src/sponsors/sponsors.controller.ts @@ -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); diff --git a/src/sponsors/sponsors.service.spec.ts b/src/sponsors/sponsors.service.spec.ts index 82ae627..ca08509 100644 --- a/src/sponsors/sponsors.service.spec.ts +++ b/src/sponsors/sponsors.service.spec.ts @@ -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 @@ -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' } }); @@ -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', diff --git a/test/users.e2e-spec.ts b/test/users.e2e-spec.ts index 4fee806..ec1c0ab 100644 --- a/test/users.e2e-spec.ts +++ b/test/users.e2e-spec.ts @@ -21,7 +21,7 @@ describe('UsersController (e2e)', () => { ], }) .overrideGuard(JwtAuthGuard) - .useValue({ canActivate: () => false }) // Simulate unauthenticated + .useValue({ canActivate: () => false }) // Simulate denied access .compile(); app = moduleFixture.createNestApplication(); @@ -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);