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
103 changes: 101 additions & 2 deletions src/milestones/milestones.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Milestone>) => Promise.resolve(m)),
};
issueRepo = {
findOne: jest.fn(),
save: jest.fn((i: Partial<Issue>) => 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({
Expand All @@ -25,7 +32,7 @@ describe('MilestonesService', () => {
{ provide: getRepositoryToken(Milestone), useValue: milestoneRepo },
{
provide: getRepositoryToken(Issue),
useValue: { findOne: jest.fn() },
useValue: issueRepo,
},
{ provide: EscrowService, useValue: escrowService },
],
Expand Down Expand Up @@ -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' });
});
});
});
7 changes: 7 additions & 0 deletions src/milestones/milestones.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down