diff --git a/src/teams/teams.service.spec.ts b/src/teams/teams.service.spec.ts new file mode 100644 index 0000000..86bcc95 --- /dev/null +++ b/src/teams/teams.service.spec.ts @@ -0,0 +1,114 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { TeamsService } from './teams.service'; +import { Bounty, Team, TeamMemberSplit } from '../common/entities'; +import { BountyStatus } from '../common/enums'; + +describe('TeamsService', () => { + let service: TeamsService; + let teamRepo: { findOne: jest.Mock; save: jest.Mock; create: jest.Mock }; + let splitRepo: { save: jest.Mock; create: jest.Mock }; + let bountyRepo: { findOne: jest.Mock; save: jest.Mock }; + + beforeEach(async () => { + teamRepo = { + findOne: jest.fn(), + save: jest.fn((t: Partial) => + Promise.resolve({ id: 'team-1', ...t }), + ), + create: jest.fn((data: Partial) => ({ id: 'team-1', ...data })), + }; + splitRepo = { + save: jest.fn((s: Partial) => + Promise.resolve({ id: 'split-1', ...s }), + ), + create: jest.fn((data: Partial) => ({ + id: 'split-1', + ...data, + })), + }; + bountyRepo = { + findOne: jest.fn(), + save: jest.fn((b: Partial) => Promise.resolve(b)), + }; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + TeamsService, + { provide: getRepositoryToken(Team), useValue: teamRepo }, + { provide: getRepositoryToken(TeamMemberSplit), useValue: splitRepo }, + { provide: getRepositoryToken(Bounty), useValue: bountyRepo }, + ], + }).compile(); + + service = module.get(TeamsService); + }); + + describe('assignToBounty', () => { + it('assigns a team to an OPEN bounty', async () => { + teamRepo.findOne.mockResolvedValue({ id: 'team-1', splits: [] }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: BountyStatus.OPEN, + claimedById: null, + }); + + const updated = await service.assignToBounty('team-1', 'bounty-1'); + expect(updated.teamId).toBe('team-1'); + expect(bountyRepo.save).toHaveBeenCalled(); + }); + + it('assigns a team to a FUNDED bounty', async () => { + teamRepo.findOne.mockResolvedValue({ id: 'team-1', splits: [] }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: BountyStatus.FUNDED, + claimedById: null, + }); + + const updated = await service.assignToBounty('team-1', 'bounty-1'); + expect(updated.teamId).toBe('team-1'); + expect(bountyRepo.save).toHaveBeenCalled(); + }); + + it('rejects assigning a team to a CLAIMED bounty', async () => { + teamRepo.findOne.mockResolvedValue({ id: 'team-1', splits: [] }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: BountyStatus.CLAIMED, + claimedById: 'user-1', + }); + + await expect( + service.assignToBounty('team-1', 'bounty-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects assigning a team to an IN_REVIEW bounty', async () => { + teamRepo.findOne.mockResolvedValue({ id: 'team-1', splits: [] }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: BountyStatus.IN_REVIEW, + claimedById: 'user-1', + }); + + await expect( + service.assignToBounty('team-1', 'bounty-1'), + ).rejects.toThrow(BadRequestException); + }); + + it('rejects assigning a team when bounty already has a claimedById', async () => { + teamRepo.findOne.mockResolvedValue({ id: 'team-1', splits: [] }); + bountyRepo.findOne.mockResolvedValue({ + id: 'bounty-1', + status: BountyStatus.FUNDED, + claimedById: 'user-1', + }); + + await expect( + service.assignToBounty('team-1', 'bounty-1'), + ).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/src/teams/teams.service.ts b/src/teams/teams.service.ts index 28f40b8..c089c64 100644 --- a/src/teams/teams.service.ts +++ b/src/teams/teams.service.ts @@ -1,7 +1,12 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Bounty, Team, TeamMemberSplit } from '../common/entities'; +import { BountyStatus } from '../common/enums'; import { CreateTeamDto } from './dto/create-team.dto'; import { validateSplitPercentages } from './team-split.util'; @@ -54,6 +59,22 @@ export class TeamsService { await this.findOne(teamId); // ensures team exists const bounty = await this.bountyRepo.findOne({ where: { id: bountyId } }); if (!bounty) throw new NotFoundException(`Bounty ${bountyId} not found`); + + if ( + bounty.status !== BountyStatus.OPEN && + bounty.status !== BountyStatus.FUNDED + ) { + throw new BadRequestException( + `Cannot assign a team to a bounty in ${bounty.status} status. Assignment is only allowed in OPEN or FUNDED state before claiming.`, + ); + } + + if (bounty.claimedById) { + throw new BadRequestException( + `Cannot assign a team to a bounty that has already been claimed by contributor ${bounty.claimedById}.`, + ); + } + bounty.teamId = teamId; return this.bountyRepo.save(bounty); }