diff --git a/src/milestones/milestones.service.spec.ts b/src/milestones/milestones.service.spec.ts index 492c562..16e362e 100644 --- a/src/milestones/milestones.service.spec.ts +++ b/src/milestones/milestones.service.spec.ts @@ -8,15 +8,22 @@ import { AssetType, MilestoneStatus } from '../common/enums'; describe('MilestonesService', () => { let service: MilestonesService; let milestoneRepo: { findOne: jest.Mock; save: jest.Mock }; - let escrowService: { fund: jest.Mock }; + let issueRepo: { findOne: jest.Mock; save: jest.Mock; update: jest.Mock }; + let escrowService: { fund: jest.Mock; releasePartial: jest.Mock }; beforeEach(async () => { milestoneRepo = { findOne: jest.fn(), save: jest.fn((m: Partial) => Promise.resolve(m)), }; + issueRepo = { + findOne: jest.fn(), + save: jest.fn((i: Partial) => Promise.resolve(i)), + update: jest.fn().mockResolvedValue({ affected: 1 }), + }; escrowService = { fund: jest.fn().mockResolvedValue({ id: 'escrow-1', status: 'locked' }), + releasePartial: jest.fn().mockResolvedValue({ id: 'payment-1', amount: '100' }), }; const module: TestingModule = await Test.createTestingModule({ @@ -25,7 +32,7 @@ describe('MilestonesService', () => { { provide: getRepositoryToken(Milestone), useValue: milestoneRepo }, { provide: getRepositoryToken(Issue), - useValue: { findOne: jest.fn() }, + useValue: issueRepo, }, { provide: EscrowService, useValue: escrowService }, ], @@ -97,4 +104,96 @@ describe('MilestonesService', () => { ); }); }); + + describe('addIssue', () => { + it('successfully attaches an issue when repositoryId matches', async () => { + milestoneRepo.findOne.mockResolvedValue({ + id: 'm-1', + repositoryId: 'repo-a', + issues: [], + }); + issueRepo.findOne.mockResolvedValue({ + id: 'issue-1', + repositoryId: 'repo-a', + milestoneId: null, + }); + + const result = await service.addIssue('m-1', 'issue-1'); + + expect(result.milestoneId).toBe('m-1'); + expect(issueRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'issue-1', + milestoneId: 'm-1', + }), + ); + }); + + it('rejects attaching an issue from a different repository with clear mismatch details', async () => { + milestoneRepo.findOne.mockResolvedValue({ + id: 'm-1', + repositoryId: 'repo-a', + issues: [], + }); + issueRepo.findOne.mockResolvedValue({ + id: 'issue-2', + repositoryId: 'repo-b', + milestoneId: null, + }); + + await expect(service.addIssue('m-1', 'issue-2')).rejects.toThrow( + 'Issue issue-2 (repository: repo-b) does not belong to Milestone m-1 repository (repo-a)', + ); + expect(issueRepo.save).not.toHaveBeenCalled(); + }); + + it('throws NotFoundException when issue does not exist', async () => { + milestoneRepo.findOne.mockResolvedValue({ + id: 'm-1', + repositoryId: 'repo-a', + issues: [], + }); + issueRepo.findOne.mockResolvedValue(null); + + await expect(service.addIssue('m-1', 'issue-missing')).rejects.toThrow( + 'Issue issue-missing not found', + ); + }); + }); + + describe('resolveIssue', () => { + it('computes proportional share and resolves issue successfully', async () => { + milestoneRepo.findOne.mockResolvedValue({ + id: 'm-1', + repositoryId: 'repo-a', + status: MilestoneStatus.FUNDED, + escrowId: 'escrow-1', + budget: '200.0000000', + distributed: '0.0000000', + issues: [ + { id: 'issue-1', state: 'open', repositoryId: 'repo-a' }, + { id: 'issue-2', state: 'open', repositoryId: 'repo-a' }, + ], + }); + + const payment = await service.resolveIssue( + 'm-1', + 'issue-1', + 'GRECIPIENT', + 'user-1', + ); + + expect(escrowService.releasePartial).toHaveBeenCalledWith( + 'escrow-1', + '100.0000000', + 'GRECIPIENT', + 'user-1', + ); + expect(issueRepo.update).toHaveBeenCalledWith('issue-1', { + state: 'closed', + closedAt: expect.any(Date), + }); + expect(payment).toEqual({ id: 'payment-1', amount: '100' }); + }); + }); }); diff --git a/src/milestones/milestones.service.ts b/src/milestones/milestones.service.ts index 43bb7fb..89e8064 100644 --- a/src/milestones/milestones.service.ts +++ b/src/milestones/milestones.service.ts @@ -70,6 +70,13 @@ export class MilestonesService { const milestone = await this.findOne(milestoneId); const issue = await this.issueRepo.findOne({ where: { id: issueId } }); if (!issue) throw new NotFoundException(`Issue ${issueId} not found`); + + if (issue.repositoryId !== milestone.repositoryId) { + throw new BadRequestException( + `Issue ${issueId} (repository: ${issue.repositoryId}) does not belong to Milestone ${milestoneId} repository (${milestone.repositoryId})`, + ); + } + issue.milestoneId = milestone.id; return this.issueRepo.save(issue); }