test: Add unit tests for commissions and sales modules
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
eb6a83daba
commit
a2a1fd3d3b
328
src/modules/commissions/__tests__/assignments.service.spec.ts
Normal file
328
src/modules/commissions/__tests__/assignments.service.spec.ts
Normal file
@ -0,0 +1,328 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { AssignmentsService } from '../services/assignments.service';
|
||||
import { CommissionAssignmentEntity, CommissionSchemeEntity, SchemeType, AppliesTo } from '../entities';
|
||||
|
||||
describe('AssignmentsService', () => {
|
||||
let service: AssignmentsService;
|
||||
let assignmentRepo: jest.Mocked<Repository<CommissionAssignmentEntity>>;
|
||||
let schemeRepo: jest.Mocked<Repository<CommissionSchemeEntity>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
const mockSchemeId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
const mockScheme: Partial<CommissionSchemeEntity> = {
|
||||
id: mockSchemeId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Standard Commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
const mockAssignment: Partial<CommissionAssignmentEntity> = {
|
||||
id: 'assignment-001',
|
||||
tenantId: mockTenantId,
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: new Date('2026-01-01'),
|
||||
endsAt: null,
|
||||
customRate: null,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
createdBy: mockUserId,
|
||||
scheme: mockScheme as CommissionSchemeEntity,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockAssignmentRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSchemeRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
AssignmentsService,
|
||||
{ provide: getRepositoryToken(CommissionAssignmentEntity), useValue: mockAssignmentRepo },
|
||||
{ provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<AssignmentsService>(AssignmentsService);
|
||||
assignmentRepo = module.get(getRepositoryToken(CommissionAssignmentEntity));
|
||||
schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an assignment successfully', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(mockAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(mockAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.userId).toBe(mockUserId);
|
||||
expect(result.schemeId).toBe(mockSchemeId);
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(schemeRepo.findOne).toHaveBeenCalled();
|
||||
expect(assignmentRepo.create).toHaveBeenCalled();
|
||||
expect(assignmentRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create an assignment with custom rate', async () => {
|
||||
const customRateAssignment = { ...mockAssignment, customRate: 15 };
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(customRateAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(customRateAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
customRate: 15,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.customRate).toBe(15);
|
||||
});
|
||||
|
||||
it('should create an assignment with date range', async () => {
|
||||
const datedAssignment = {
|
||||
...mockAssignment,
|
||||
startsAt: new Date('2026-01-01'),
|
||||
endsAt: new Date('2026-12-31'),
|
||||
};
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(datedAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(datedAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: '2026-01-01',
|
||||
endsAt: '2026-12-31',
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.startsAt).toBeDefined();
|
||||
expect(result.endsAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: 'invalid-scheme-id',
|
||||
};
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, dto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated assignments', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { userId: mockUserId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.user_id = :userId',
|
||||
{ userId: mockUserId },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by schemeId', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockAssignment], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { schemeId: mockSchemeId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.scheme_id = :schemeId',
|
||||
{ schemeId: mockSchemeId },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return an assignment by id', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, 'assignment-001');
|
||||
|
||||
expect(result.id).toBe('assignment-001');
|
||||
expect(result.userId).toBe(mockUserId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an assignment successfully', async () => {
|
||||
const updatedAssignment = { ...mockAssignment, customRate: 20 };
|
||||
assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'assignment-001', { customRate: 20 });
|
||||
|
||||
expect(result.customRate).toBe(20);
|
||||
});
|
||||
|
||||
it('should update dates', async () => {
|
||||
const updatedAssignment = {
|
||||
...mockAssignment,
|
||||
endsAt: new Date('2026-06-30'),
|
||||
};
|
||||
assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'assignment-001', {
|
||||
endsAt: '2026-06-30',
|
||||
});
|
||||
|
||||
expect(result.endsAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'invalid-id', { customRate: 20 }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete an assignment', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(mockAssignment as CommissionAssignmentEntity);
|
||||
|
||||
await service.remove(mockTenantId, 'assignment-001');
|
||||
|
||||
expect(assignmentRepo.remove).toHaveBeenCalledWith(mockAssignment);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveForUser', () => {
|
||||
it('should return active assignments for a user', async () => {
|
||||
const now = new Date();
|
||||
const activeAssignment = {
|
||||
...mockAssignment,
|
||||
startsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
endsAt: new Date(now.getTime() + 86400000), // tomorrow
|
||||
};
|
||||
assignmentRepo.find.mockResolvedValue([activeAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].userId).toBe(mockUserId);
|
||||
});
|
||||
|
||||
it('should filter out expired assignments', async () => {
|
||||
const now = new Date();
|
||||
const expiredAssignment = {
|
||||
...mockAssignment,
|
||||
startsAt: new Date(now.getTime() - 172800000), // 2 days ago
|
||||
endsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
};
|
||||
assignmentRepo.find.mockResolvedValue([expiredAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include assignments without end date', async () => {
|
||||
const now = new Date();
|
||||
const openEndedAssignment = {
|
||||
...mockAssignment,
|
||||
startsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
endsAt: null,
|
||||
};
|
||||
assignmentRepo.find.mockResolvedValue([openEndedAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,338 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { CommissionsDashboardService } from '../services/commissions-dashboard.service';
|
||||
import {
|
||||
CommissionSchemeEntity,
|
||||
CommissionAssignmentEntity,
|
||||
CommissionEntryEntity,
|
||||
CommissionPeriodEntity,
|
||||
EntryStatus,
|
||||
PeriodStatus,
|
||||
} from '../entities';
|
||||
|
||||
describe('CommissionsDashboardService', () => {
|
||||
let service: CommissionsDashboardService;
|
||||
let schemeRepo: jest.Mocked<Repository<CommissionSchemeEntity>>;
|
||||
let assignmentRepo: jest.Mocked<Repository<CommissionAssignmentEntity>>;
|
||||
let entryRepo: jest.Mocked<Repository<CommissionEntryEntity>>;
|
||||
let periodRepo: jest.Mocked<Repository<CommissionPeriodEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSchemeRepo = {
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockAssignmentRepo = {
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEntryRepo = {
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockPeriodRepo = {
|
||||
findOne: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CommissionsDashboardService,
|
||||
{ provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo },
|
||||
{ provide: getRepositoryToken(CommissionAssignmentEntity), useValue: mockAssignmentRepo },
|
||||
{ provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo },
|
||||
{ provide: getRepositoryToken(CommissionPeriodEntity), useValue: mockPeriodRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CommissionsDashboardService>(CommissionsDashboardService);
|
||||
schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity));
|
||||
assignmentRepo = module.get(getRepositoryToken(CommissionAssignmentEntity));
|
||||
entryRepo = module.get(getRepositoryToken(CommissionEntryEntity));
|
||||
periodRepo = module.get(getRepositoryToken(CommissionPeriodEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getDashboardSummary', () => {
|
||||
it('should return complete dashboard summary', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ status: EntryStatus.PENDING, count: 10, total: 1000 },
|
||||
{ status: EntryStatus.APPROVED, count: 5, total: 500 },
|
||||
{ status: EntryStatus.PAID, count: 15, total: 1500 },
|
||||
]),
|
||||
};
|
||||
|
||||
const mockPeriod = {
|
||||
id: 'period-001',
|
||||
name: 'January 2026',
|
||||
status: PeriodStatus.OPEN,
|
||||
};
|
||||
|
||||
schemeRepo.count.mockResolvedValue(3);
|
||||
assignmentRepo.count.mockResolvedValue(10);
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.getDashboardSummary(mockTenantId);
|
||||
|
||||
expect(result.totalSchemes).toBe(3);
|
||||
expect(result.totalActiveAssignments).toBe(10);
|
||||
expect(result.pendingCommissions).toBe(10);
|
||||
expect(result.pendingAmount).toBe(1000);
|
||||
expect(result.approvedAmount).toBe(500);
|
||||
expect(result.paidAmount).toBe(1500);
|
||||
expect(result.currentPeriod).toBe('January 2026');
|
||||
expect(result.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('should handle no entries', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
schemeRepo.count.mockResolvedValue(0);
|
||||
assignmentRepo.count.mockResolvedValue(0);
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getDashboardSummary(mockTenantId);
|
||||
|
||||
expect(result.totalSchemes).toBe(0);
|
||||
expect(result.pendingCommissions).toBe(0);
|
||||
expect(result.pendingAmount).toBe(0);
|
||||
expect(result.currentPeriod).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEarnings', () => {
|
||||
it('should return user earnings summary', async () => {
|
||||
dataSource.query.mockResolvedValue([{
|
||||
total_pending: 500,
|
||||
total_approved: 300,
|
||||
total_paid: 1200,
|
||||
total_entries: 20,
|
||||
currency: 'USD',
|
||||
}]);
|
||||
|
||||
const result = await service.getUserEarnings(mockTenantId, mockUserId);
|
||||
|
||||
expect(result.totalPending).toBe(500);
|
||||
expect(result.totalApproved).toBe(300);
|
||||
expect(result.totalPaid).toBe(1200);
|
||||
expect(result.totalEntries).toBe(20);
|
||||
expect(result.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('should return zero values when no earnings', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getUserEarnings(mockTenantId, mockUserId);
|
||||
|
||||
expect(result.totalPending).toBe(0);
|
||||
expect(result.totalApproved).toBe(0);
|
||||
expect(result.totalPaid).toBe(0);
|
||||
expect(result.totalEntries).toBe(0);
|
||||
});
|
||||
|
||||
it('should filter by date range', async () => {
|
||||
dataSource.query.mockResolvedValue([{
|
||||
total_pending: 100,
|
||||
total_approved: 200,
|
||||
total_paid: 300,
|
||||
total_entries: 5,
|
||||
currency: 'USD',
|
||||
}]);
|
||||
|
||||
const startDate = new Date('2026-01-01');
|
||||
const endDate = new Date('2026-01-31');
|
||||
|
||||
await service.getUserEarnings(mockTenantId, mockUserId, startDate, endDate);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT * FROM commissions.get_user_earnings($1, $2, $3, $4)`,
|
||||
[mockUserId, mockTenantId, startDate, endDate],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntriesByStatus', () => {
|
||||
it('should return entries grouped by status', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ status: EntryStatus.PENDING, count: 10, totalAmount: 1000 },
|
||||
{ status: EntryStatus.APPROVED, count: 5, totalAmount: 500 },
|
||||
{ status: EntryStatus.PAID, count: 15, totalAmount: 1500 },
|
||||
]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getEntriesByStatus(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].status).toBe(EntryStatus.PENDING);
|
||||
expect(result[0].count).toBe(10);
|
||||
expect(result[0].totalAmount).toBe(1000);
|
||||
expect(result[0].percentage).toBe(33); // 10/30 ≈ 33%
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getEntriesByStatus(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntriesByScheme', () => {
|
||||
it('should return entries grouped by scheme', async () => {
|
||||
const mockQueryBuilder = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ schemeId: 'scheme-001', schemeName: 'Standard', count: 20, totalAmount: 2000 },
|
||||
{ schemeId: 'scheme-002', schemeName: 'Premium', count: 10, totalAmount: 1500 },
|
||||
]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getEntriesByScheme(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].schemeId).toBe('scheme-001');
|
||||
expect(result[0].schemeName).toBe('Standard');
|
||||
expect(result[0].count).toBe(20);
|
||||
expect(result[0].totalAmount).toBe(2000);
|
||||
expect(result[0].percentage).toBe(67); // 20/30 ≈ 67%
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockQueryBuilder = {
|
||||
innerJoin: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
addGroupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getEntriesByScheme(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEntriesByUser', () => {
|
||||
it('should return entries grouped by user', async () => {
|
||||
dataSource.query.mockResolvedValue([
|
||||
{ userId: 'user-001', userName: 'John Doe', count: 15, totalAmount: 1500 },
|
||||
{ userId: 'user-002', userName: 'Jane Smith', count: 10, totalAmount: 1000 },
|
||||
{ userId: 'user-003', userName: 'Bob Wilson', count: 5, totalAmount: 500 },
|
||||
]);
|
||||
|
||||
const result = await service.getEntriesByUser(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].userId).toBe('user-001');
|
||||
expect(result[0].userName).toBe('John Doe');
|
||||
expect(result[0].count).toBe(15);
|
||||
expect(result[0].totalAmount).toBe(1500);
|
||||
expect(result[0].percentage).toBe(50); // 15/30 = 50%
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getEntriesByUser(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopEarners', () => {
|
||||
it('should return top earners with default limit', async () => {
|
||||
const allUsers = Array.from({ length: 15 }, (_, i) => ({
|
||||
userId: `user-${i}`,
|
||||
userName: `User ${i}`,
|
||||
count: 15 - i,
|
||||
totalAmount: (15 - i) * 100,
|
||||
}));
|
||||
|
||||
dataSource.query.mockResolvedValue(allUsers);
|
||||
|
||||
const result = await service.getTopEarners(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(10); // default limit
|
||||
expect(result[0].userId).toBe('user-0');
|
||||
});
|
||||
|
||||
it('should return top earners with custom limit', async () => {
|
||||
const allUsers = Array.from({ length: 15 }, (_, i) => ({
|
||||
userId: `user-${i}`,
|
||||
userName: `User ${i}`,
|
||||
count: 15 - i,
|
||||
totalAmount: (15 - i) * 100,
|
||||
}));
|
||||
|
||||
dataSource.query.mockResolvedValue(allUsers);
|
||||
|
||||
const result = await service.getTopEarners(mockTenantId, 5);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('should handle fewer users than limit', async () => {
|
||||
dataSource.query.mockResolvedValue([
|
||||
{ userId: 'user-001', userName: 'John Doe', count: 5, totalAmount: 500 },
|
||||
]);
|
||||
|
||||
const result = await service.getTopEarners(mockTenantId, 10);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
455
src/modules/commissions/__tests__/entries.service.spec.ts
Normal file
455
src/modules/commissions/__tests__/entries.service.spec.ts
Normal file
@ -0,0 +1,455 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { EntriesService } from '../services/entries.service';
|
||||
import { CommissionEntryEntity, EntryStatus } from '../entities';
|
||||
|
||||
describe('EntriesService', () => {
|
||||
let service: EntriesService;
|
||||
let entryRepo: jest.Mocked<Repository<CommissionEntryEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
const mockSchemeId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
const mockEntry: Partial<CommissionEntryEntity> = {
|
||||
id: 'entry-001',
|
||||
tenantId: mockTenantId,
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
assignmentId: null,
|
||||
referenceType: 'sale',
|
||||
referenceId: 'sale-001',
|
||||
baseAmount: 1000,
|
||||
rateApplied: 10,
|
||||
commissionAmount: 100,
|
||||
currency: 'USD',
|
||||
status: EntryStatus.PENDING,
|
||||
periodId: null,
|
||||
paidAt: null,
|
||||
paymentReference: null,
|
||||
notes: null,
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
approvedBy: null,
|
||||
approvedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEntryRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
EntriesService,
|
||||
{ provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<EntriesService>(EntriesService);
|
||||
entryRepo = module.get(getRepositoryToken(CommissionEntryEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
const createMockEntry = (overrides: Partial<CommissionEntryEntity> = {}): CommissionEntryEntity => ({
|
||||
id: 'entry-001',
|
||||
tenantId: mockTenantId,
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
assignmentId: null as any,
|
||||
referenceType: 'sale',
|
||||
referenceId: 'sale-001',
|
||||
baseAmount: 1000,
|
||||
rateApplied: 10,
|
||||
commissionAmount: 100,
|
||||
currency: 'USD',
|
||||
status: EntryStatus.PENDING,
|
||||
periodId: null as any,
|
||||
paidAt: null as any,
|
||||
paymentReference: null as any,
|
||||
notes: null as any,
|
||||
metadata: {},
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
approvedBy: null as any,
|
||||
approvedAt: null as any,
|
||||
scheme: null as any,
|
||||
assignment: null as any,
|
||||
period: null as any,
|
||||
...overrides,
|
||||
} as CommissionEntryEntity);
|
||||
|
||||
describe('create', () => {
|
||||
it('should create an entry with calculated commission', async () => {
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
entryRepo.create.mockReturnValue(mockEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(mockEntry as CommissionEntryEntity);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: 'sale-001',
|
||||
baseAmount: 1000,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.baseAmount).toBe(1000);
|
||||
expect(result.rateApplied).toBe(10);
|
||||
expect(result.commissionAmount).toBe(100);
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
|
||||
[dto.schemeId, dto.userId, dto.baseAmount, mockTenantId],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when commission is zero', async () => {
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 0, commission_amount: 0 }]);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: 'sale-001',
|
||||
baseAmount: 1000,
|
||||
};
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, dto)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should create an entry with metadata', async () => {
|
||||
const entryWithMetadata = {
|
||||
...mockEntry,
|
||||
metadata: { source: 'web', campaign: 'summer2026' },
|
||||
};
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
entryRepo.create.mockReturnValue(entryWithMetadata as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(entryWithMetadata as CommissionEntryEntity);
|
||||
|
||||
const dto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: 'sale-001',
|
||||
baseAmount: 1000,
|
||||
metadata: { source: 'web', campaign: 'summer2026' },
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.metadata).toEqual({ source: 'web', campaign: 'summer2026' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated entries', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { status: EntryStatus.PENDING });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'e.status = :status',
|
||||
{ status: EntryStatus.PENDING },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { userId: mockUserId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'e.user_id = :userId',
|
||||
{ userId: mockUserId },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by referenceType', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockEntry], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { referenceType: 'sale' });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'e.reference_type = :referenceType',
|
||||
{ referenceType: 'sale' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return an entry by id', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, 'entry-001');
|
||||
|
||||
expect(result.id).toBe('entry-001');
|
||||
expect(result.commissionAmount).toBe(100);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an entry', async () => {
|
||||
const updatedEntry = { ...mockEntry, notes: 'Updated notes' };
|
||||
entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'entry-001', { notes: 'Updated notes' });
|
||||
|
||||
expect(result.notes).toBe('Updated notes');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'invalid-id', { notes: 'Test' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('should approve a pending entry', async () => {
|
||||
const approvedEntry = {
|
||||
...mockEntry,
|
||||
status: EntryStatus.APPROVED,
|
||||
approvedBy: mockUserId,
|
||||
approvedAt: new Date(),
|
||||
};
|
||||
entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.approve(mockTenantId, 'entry-001', mockUserId, {});
|
||||
|
||||
expect(result.status).toBe(EntryStatus.APPROVED);
|
||||
expect(result.approvedBy).toBe(mockUserId);
|
||||
expect(result.approvedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if entry is not pending', async () => {
|
||||
const approvedEntry = { ...mockEntry, status: EntryStatus.APPROVED };
|
||||
entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
|
||||
await expect(
|
||||
service.approve(mockTenantId, 'entry-001', mockUserId, {}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.approve(mockTenantId, 'invalid-id', mockUserId, {}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should reject a pending entry', async () => {
|
||||
const pendingEntry = createMockEntry({ status: EntryStatus.PENDING });
|
||||
const rejectedEntry = createMockEntry({
|
||||
status: EntryStatus.REJECTED,
|
||||
approvedBy: mockUserId,
|
||||
approvedAt: new Date(),
|
||||
notes: 'Invalid sale',
|
||||
});
|
||||
entryRepo.findOne.mockResolvedValue(pendingEntry);
|
||||
entryRepo.save.mockResolvedValue(rejectedEntry);
|
||||
|
||||
const result = await service.reject(mockTenantId, 'entry-001', mockUserId, {
|
||||
notes: 'Invalid sale',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(EntryStatus.REJECTED);
|
||||
expect(result.notes).toBe('Invalid sale');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if entry is not pending', async () => {
|
||||
const approvedEntry = createMockEntry({ status: EntryStatus.APPROVED });
|
||||
entryRepo.findOne.mockResolvedValue(approvedEntry);
|
||||
|
||||
await expect(
|
||||
service.reject(mockTenantId, 'entry-001', mockUserId, { notes: 'Reason' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCommission', () => {
|
||||
it('should calculate commission without creating entry', async () => {
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
|
||||
const result = await service.calculateCommission(mockTenantId, {
|
||||
schemeId: mockSchemeId,
|
||||
userId: mockUserId,
|
||||
amount: 1000,
|
||||
});
|
||||
|
||||
expect(result.rateApplied).toBe(10);
|
||||
expect(result.commissionAmount).toBe(100);
|
||||
});
|
||||
|
||||
it('should return zero for invalid calculation', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateCommission(mockTenantId, {
|
||||
schemeId: mockSchemeId,
|
||||
userId: mockUserId,
|
||||
amount: 1000,
|
||||
});
|
||||
|
||||
expect(result.rateApplied).toBe(0);
|
||||
expect(result.commissionAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPaid', () => {
|
||||
it('should mark an approved entry as paid', async () => {
|
||||
const approvedEntry = { ...mockEntry, status: EntryStatus.APPROVED };
|
||||
const paidEntry = {
|
||||
...approvedEntry,
|
||||
status: EntryStatus.PAID,
|
||||
paidAt: new Date(),
|
||||
paymentReference: 'PAY-001',
|
||||
};
|
||||
entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(paidEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.markAsPaid(mockTenantId, 'entry-001', 'PAY-001');
|
||||
|
||||
expect(result.status).toBe(EntryStatus.PAID);
|
||||
expect(result.paymentReference).toBe('PAY-001');
|
||||
expect(result.paidAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if entry is not approved', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(mockEntry as CommissionEntryEntity);
|
||||
|
||||
await expect(
|
||||
service.markAsPaid(mockTenantId, 'entry-001', 'PAY-001'),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.markAsPaid(mockTenantId, 'invalid-id', 'PAY-001'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkApprove', () => {
|
||||
it('should bulk approve multiple entries', async () => {
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 3 }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.bulkApprove(
|
||||
mockTenantId,
|
||||
['entry-001', 'entry-002', 'entry-003'],
|
||||
mockUserId,
|
||||
);
|
||||
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('should return zero when no entries match', async () => {
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 0 }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.bulkApprove(mockTenantId, ['invalid-id'], mockUserId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
382
src/modules/commissions/__tests__/periods.service.spec.ts
Normal file
382
src/modules/commissions/__tests__/periods.service.spec.ts
Normal file
@ -0,0 +1,382 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { PeriodsService } from '../services/periods.service';
|
||||
import { CommissionPeriodEntity, PeriodStatus, CommissionEntryEntity, EntryStatus } from '../entities';
|
||||
|
||||
describe('PeriodsService', () => {
|
||||
let service: PeriodsService;
|
||||
let periodRepo: jest.Mocked<Repository<CommissionPeriodEntity>>;
|
||||
let entryRepo: jest.Mocked<Repository<CommissionEntryEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockPeriod: Partial<CommissionPeriodEntity> = {
|
||||
id: 'period-001',
|
||||
tenantId: mockTenantId,
|
||||
name: 'January 2026',
|
||||
startsAt: new Date('2026-01-01'),
|
||||
endsAt: new Date('2026-01-31'),
|
||||
totalEntries: 0,
|
||||
totalAmount: 0,
|
||||
currency: 'USD',
|
||||
status: PeriodStatus.OPEN,
|
||||
closedAt: null,
|
||||
closedBy: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
paymentReference: null,
|
||||
paymentNotes: null,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
createdBy: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockPeriodRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockEntryRepo = {
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PeriodsService,
|
||||
{ provide: getRepositoryToken(CommissionPeriodEntity), useValue: mockPeriodRepo },
|
||||
{ provide: getRepositoryToken(CommissionEntryEntity), useValue: mockEntryRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PeriodsService>(PeriodsService);
|
||||
periodRepo = module.get(getRepositoryToken(CommissionPeriodEntity));
|
||||
entryRepo = module.get(getRepositoryToken(CommissionEntryEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a period successfully', async () => {
|
||||
periodRepo.create.mockReturnValue(mockPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'January 2026',
|
||||
startsAt: '2026-01-01',
|
||||
endsAt: '2026-01-31',
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.name).toBe('January 2026');
|
||||
expect(result.status).toBe(PeriodStatus.OPEN);
|
||||
expect(periodRepo.create).toHaveBeenCalled();
|
||||
expect(periodRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a period with custom currency', async () => {
|
||||
const eurPeriod = { ...mockPeriod, currency: 'EUR' };
|
||||
periodRepo.create.mockReturnValue(eurPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(eurPeriod as CommissionPeriodEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'January 2026',
|
||||
startsAt: '2026-01-01',
|
||||
endsAt: '2026-01-31',
|
||||
currency: 'EUR',
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated periods', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockPeriod], 1]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockPeriod], 1]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { status: PeriodStatus.OPEN });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.status = :status',
|
||||
{ status: PeriodStatus.OPEN },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a period by id', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, 'period-001');
|
||||
|
||||
expect(result.id).toBe('period-001');
|
||||
expect(result.name).toBe('January 2026');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an open period', async () => {
|
||||
const updatedPeriod = { ...mockPeriod, name: 'Updated Period' };
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(updatedPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'period-001', { name: 'Updated Period' });
|
||||
|
||||
expect(result.name).toBe('Updated Period');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for non-open period', async () => {
|
||||
const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'period-001', { name: 'Test' }),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'invalid-id', { name: 'Test' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should close an open period', async () => {
|
||||
const closedPeriod = {
|
||||
...mockPeriod,
|
||||
status: PeriodStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
closedBy: mockUserId,
|
||||
};
|
||||
periodRepo.findOne
|
||||
.mockResolvedValueOnce(mockPeriod as CommissionPeriodEntity)
|
||||
.mockResolvedValueOnce(closedPeriod as CommissionPeriodEntity);
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.close(mockTenantId, 'period-001', mockUserId, {});
|
||||
|
||||
expect(result.status).toBe(PeriodStatus.CLOSED);
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT commissions.close_period($1, $2)`,
|
||||
['period-001', mockUserId],
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for non-open period', async () => {
|
||||
const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(
|
||||
service.close(mockTenantId, 'period-001', mockUserId, {}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.close(mockTenantId, 'invalid-id', mockUserId, {}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPaid', () => {
|
||||
it('should mark a closed period as paid', async () => {
|
||||
const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED };
|
||||
const paidPeriod = {
|
||||
...closedPeriod,
|
||||
status: PeriodStatus.PAID,
|
||||
paidAt: new Date(),
|
||||
paidBy: mockUserId,
|
||||
paymentReference: 'PAY-001',
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 5 }),
|
||||
};
|
||||
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity);
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.markAsPaid(mockTenantId, 'period-001', mockUserId, {
|
||||
paymentReference: 'PAY-001',
|
||||
});
|
||||
|
||||
expect(result.status).toBe(PeriodStatus.PAID);
|
||||
expect(result.paymentReference).toBe('PAY-001');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for open period', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(
|
||||
service.markAsPaid(mockTenantId, 'period-001', mockUserId, {}),
|
||||
).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.markAsPaid(mockTenantId, 'invalid-id', mockUserId, {}),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete an open period without entries', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
entryRepo.count.mockResolvedValue(0);
|
||||
|
||||
await service.remove(mockTenantId, 'period-001');
|
||||
|
||||
expect(periodRepo.remove).toHaveBeenCalledWith(mockPeriod);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException for non-open period', async () => {
|
||||
const closedPeriod = { ...mockPeriod, status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'period-001')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException if period has entries', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
entryRepo.count.mockResolvedValue(5);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'period-001')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentPeriod', () => {
|
||||
it('should return the current open period', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(mockPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.getCurrentPeriod(mockTenantId);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).toBe(PeriodStatus.OPEN);
|
||||
});
|
||||
|
||||
it('should return null if no open period', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
const result = await service.getCurrentPeriod(mockTenantId);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignEntriesToPeriod', () => {
|
||||
it('should assign entries to a period', async () => {
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 3 }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.assignEntriesToPeriod(
|
||||
mockTenantId,
|
||||
'period-001',
|
||||
['entry-001', 'entry-002', 'entry-003'],
|
||||
);
|
||||
|
||||
expect(result).toBe(3);
|
||||
});
|
||||
|
||||
it('should return zero when no entries match', async () => {
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 0 }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.assignEntriesToPeriod(mockTenantId, 'period-001', []);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
342
src/modules/commissions/__tests__/schemes.service.spec.ts
Normal file
342
src/modules/commissions/__tests__/schemes.service.spec.ts
Normal file
@ -0,0 +1,342 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { SchemesService } from '../services/schemes.service';
|
||||
import { CommissionSchemeEntity, SchemeType, AppliesTo } from '../entities';
|
||||
|
||||
describe('SchemesService', () => {
|
||||
let service: SchemesService;
|
||||
let schemeRepo: jest.Mocked<Repository<CommissionSchemeEntity>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockScheme: Partial<CommissionSchemeEntity> = {
|
||||
id: 'scheme-001',
|
||||
tenantId: mockTenantId,
|
||||
name: 'Standard Commission',
|
||||
description: 'Standard percentage commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
fixedAmount: 0,
|
||||
tiers: [],
|
||||
appliesTo: AppliesTo.ALL,
|
||||
productIds: [],
|
||||
categoryIds: [],
|
||||
minAmount: 0,
|
||||
maxAmount: null,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
createdBy: mockUserId,
|
||||
deletedAt: null,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSchemeRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SchemesService,
|
||||
{ provide: getRepositoryToken(CommissionSchemeEntity), useValue: mockSchemeRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SchemesService>(SchemesService);
|
||||
schemeRepo = module.get(getRepositoryToken(CommissionSchemeEntity));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a percentage scheme successfully', async () => {
|
||||
schemeRepo.create.mockReturnValue(mockScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Standard Commission',
|
||||
description: 'Standard percentage commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
appliesTo: AppliesTo.ALL,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.name).toBe('Standard Commission');
|
||||
expect(result.type).toBe(SchemeType.PERCENTAGE);
|
||||
expect(result.rate).toBe(10);
|
||||
expect(schemeRepo.create).toHaveBeenCalled();
|
||||
expect(schemeRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a fixed amount scheme', async () => {
|
||||
const fixedScheme = { ...mockScheme, type: SchemeType.FIXED, fixedAmount: 50 };
|
||||
schemeRepo.create.mockReturnValue(fixedScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(fixedScheme as CommissionSchemeEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Fixed Commission',
|
||||
type: SchemeType.FIXED,
|
||||
fixedAmount: 50,
|
||||
appliesTo: AppliesTo.ALL,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.type).toBe(SchemeType.FIXED);
|
||||
expect(result.fixedAmount).toBe(50);
|
||||
});
|
||||
|
||||
it('should create a tiered scheme', async () => {
|
||||
const tiers = [
|
||||
{ from: 0, to: 1000, rate: 5 },
|
||||
{ from: 1000, to: 5000, rate: 10 },
|
||||
{ from: 5000, to: null, rate: 15 },
|
||||
];
|
||||
const tieredScheme = { ...mockScheme, type: SchemeType.TIERED, tiers };
|
||||
schemeRepo.create.mockReturnValue(tieredScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(tieredScheme as CommissionSchemeEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Tiered Commission',
|
||||
type: SchemeType.TIERED,
|
||||
tiers,
|
||||
appliesTo: AppliesTo.ALL,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.type).toBe(SchemeType.TIERED);
|
||||
expect(result.tiers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should create a scheme with product restrictions', async () => {
|
||||
const productScheme = {
|
||||
...mockScheme,
|
||||
appliesTo: AppliesTo.PRODUCTS,
|
||||
productIds: ['product-001', 'product-002'],
|
||||
};
|
||||
schemeRepo.create.mockReturnValue(productScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(productScheme as CommissionSchemeEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Product Commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 15,
|
||||
appliesTo: AppliesTo.PRODUCTS,
|
||||
productIds: ['product-001', 'product-002'],
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, dto);
|
||||
|
||||
expect(result.appliesTo).toBe(AppliesTo.PRODUCTS);
|
||||
expect(result.productIds).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated schemes', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 10 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { type: SchemeType.PERCENTAGE });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
's.type = :type',
|
||||
{ type: SchemeType.PERCENTAGE },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by isActive', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { isActive: true });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
's.is_active = :isActive',
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should search by name or description', async () => {
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockScheme], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { search: 'Standard' });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(s.name ILIKE :search OR s.description ILIKE :search)',
|
||||
{ search: '%Standard%' },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a scheme by id', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, 'scheme-001');
|
||||
|
||||
expect(result.id).toBe('scheme-001');
|
||||
expect(result.name).toBe('Standard Commission');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a scheme successfully', async () => {
|
||||
const updatedScheme = { ...mockScheme, name: 'Updated Commission', rate: 15 };
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(updatedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'scheme-001', {
|
||||
name: 'Updated Commission',
|
||||
rate: 15,
|
||||
});
|
||||
|
||||
expect(result.name).toBe('Updated Commission');
|
||||
expect(result.rate).toBe(15);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'invalid-id', { name: 'Test' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft delete a scheme', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue({
|
||||
...mockScheme,
|
||||
deletedAt: new Date(),
|
||||
} as CommissionSchemeEntity);
|
||||
|
||||
await service.remove(mockTenantId, 'scheme-001');
|
||||
|
||||
expect(schemeRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate', () => {
|
||||
it('should activate a scheme', async () => {
|
||||
const inactiveScheme = { ...mockScheme, isActive: false };
|
||||
schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue({
|
||||
...inactiveScheme,
|
||||
isActive: true,
|
||||
} as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.activate(mockTenantId, 'scheme-001');
|
||||
|
||||
expect(result.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.activate(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('should deactivate a scheme', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(mockScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue({
|
||||
...mockScheme,
|
||||
isActive: false,
|
||||
} as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.deactivate(mockTenantId, 'scheme-001');
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivate(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
342
src/modules/sales/__tests__/pipeline.service.spec.ts
Normal file
342
src/modules/sales/__tests__/pipeline.service.spec.ts
Normal file
@ -0,0 +1,342 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { PipelineService } from '../services/pipeline.service';
|
||||
import { PipelineStageEntity, OpportunityEntity } from '../entities';
|
||||
|
||||
describe('PipelineService', () => {
|
||||
let service: PipelineService;
|
||||
let stageRepo: jest.Mocked<Repository<PipelineStageEntity>>;
|
||||
let opportunityRepo: jest.Mocked<Repository<OpportunityEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
const mockStage: Partial<PipelineStageEntity> = {
|
||||
id: 'stage-001',
|
||||
tenantId: mockTenantId,
|
||||
name: 'Qualification',
|
||||
position: 1,
|
||||
color: '#3B82F6',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
};
|
||||
|
||||
const mockStages: Partial<PipelineStageEntity>[] = [
|
||||
mockStage,
|
||||
{
|
||||
id: 'stage-002',
|
||||
tenantId: mockTenantId,
|
||||
name: 'Proposal',
|
||||
position: 2,
|
||||
color: '#F59E0B',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'stage-003',
|
||||
tenantId: mockTenantId,
|
||||
name: 'Closed Won',
|
||||
position: 3,
|
||||
color: '#10B981',
|
||||
isWon: true,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockStageRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
remove: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockOpportunityRepo = {
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PipelineService,
|
||||
{ provide: getRepositoryToken(PipelineStageEntity), useValue: mockStageRepo },
|
||||
{ provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<PipelineService>(PipelineService);
|
||||
stageRepo = module.get(getRepositoryToken(PipelineStageEntity));
|
||||
opportunityRepo = module.get(getRepositoryToken(OpportunityEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('initializeDefaults', () => {
|
||||
it('should call database function to initialize default stages', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
await service.initializeDefaults(mockTenantId);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT sales.initialize_default_stages($1)`,
|
||||
[mockTenantId],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all stages with opportunity stats', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ stageId: 'stage-001', count: 5, total: 50000 },
|
||||
{ stageId: 'stage-002', count: 3, total: 30000 },
|
||||
]),
|
||||
};
|
||||
|
||||
stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].opportunityCount).toBe(5);
|
||||
expect(result[0].totalAmount).toBe(50000);
|
||||
expect(result[2].opportunityCount).toBe(0);
|
||||
expect(stageRepo.find).toHaveBeenCalledWith({
|
||||
where: { tenantId: mockTenantId },
|
||||
order: { position: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stages with zero stats when no opportunities', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
stageRepo.find.mockResolvedValue([mockStage] as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].opportunityCount).toBe(0);
|
||||
expect(result[0].totalAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a stage by id', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, 'stage-001');
|
||||
|
||||
expect(result.id).toBe('stage-001');
|
||||
expect(result.name).toBe('Qualification');
|
||||
expect(stageRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'stage-001', tenantId: mockTenantId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new stage at the end', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({ max: 2 }),
|
||||
};
|
||||
|
||||
stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
stageRepo.create.mockReturnValue(mockStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Qualification',
|
||||
color: '#3B82F6',
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, dto);
|
||||
|
||||
expect(result.name).toBe('Qualification');
|
||||
expect(stageRepo.create).toHaveBeenCalled();
|
||||
expect(stageRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a stage with custom position', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({ max: 5 }),
|
||||
};
|
||||
|
||||
stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
stageRepo.create.mockReturnValue({ ...mockStage, position: 3 } as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({ ...mockStage, position: 3 } as PipelineStageEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Custom Stage',
|
||||
position: 3,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, dto);
|
||||
|
||||
expect(stageRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ position: 3 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a won stage', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({ max: 2 }),
|
||||
};
|
||||
|
||||
const wonStage = { ...mockStage, isWon: true };
|
||||
stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
stageRepo.create.mockReturnValue(wonStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(wonStage as PipelineStageEntity);
|
||||
|
||||
const dto = {
|
||||
name: 'Closed Won',
|
||||
isWon: true,
|
||||
};
|
||||
|
||||
const result = await service.create(mockTenantId, dto);
|
||||
|
||||
expect(result.isWon).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a stage successfully', async () => {
|
||||
const updatedStage = { ...mockStage, name: 'Updated Stage' };
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, 'stage-001', { name: 'Updated Stage' });
|
||||
|
||||
expect(result.name).toBe('Updated Stage');
|
||||
expect(stageRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.update(mockTenantId, 'invalid-id', { name: 'Test' }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete a stage without opportunities', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
opportunityRepo.count.mockResolvedValue(0);
|
||||
|
||||
await service.remove(mockTenantId, 'stage-001');
|
||||
|
||||
expect(stageRepo.remove).toHaveBeenCalledWith(mockStage);
|
||||
});
|
||||
|
||||
it('should throw error when stage has opportunities', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
opportunityRepo.count.mockResolvedValue(5);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'stage-001')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException if stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'invalid-id')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
it('should reorder stages successfully', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]);
|
||||
stageRepo.save.mockResolvedValue(mockStages as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const dto = {
|
||||
stageIds: ['stage-003', 'stage-001', 'stage-002'],
|
||||
};
|
||||
|
||||
const result = await service.reorder(mockTenantId, dto);
|
||||
|
||||
expect(stageRepo.save).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty stage list', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
stageRepo.find.mockResolvedValue([]);
|
||||
stageRepo.save.mockResolvedValue([]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const dto = { stageIds: [] };
|
||||
|
||||
const result = await service.reorder(mockTenantId, dto);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
308
src/modules/sales/__tests__/sales-dashboard.service.spec.ts
Normal file
308
src/modules/sales/__tests__/sales-dashboard.service.spec.ts
Normal file
@ -0,0 +1,308 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
|
||||
import { SalesDashboardService } from '../services/sales-dashboard.service';
|
||||
import { LeadEntity, OpportunityEntity, ActivityEntity, OpportunityStage, LeadStatus, ActivityStatus } from '../entities';
|
||||
|
||||
describe('SalesDashboardService', () => {
|
||||
let service: SalesDashboardService;
|
||||
let leadRepo: jest.Mocked<Repository<LeadEntity>>;
|
||||
let opportunityRepo: jest.Mocked<Repository<OpportunityEntity>>;
|
||||
let activityRepo: jest.Mocked<Repository<ActivityEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockLeadRepo = {
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockOpportunityRepo = {
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockActivityRepo = {
|
||||
count: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
SalesDashboardService,
|
||||
{ provide: getRepositoryToken(LeadEntity), useValue: mockLeadRepo },
|
||||
{ provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo },
|
||||
{ provide: getRepositoryToken(ActivityEntity), useValue: mockActivityRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<SalesDashboardService>(SalesDashboardService);
|
||||
leadRepo = module.get(getRepositoryToken(LeadEntity));
|
||||
opportunityRepo = module.get(getRepositoryToken(OpportunityEntity));
|
||||
activityRepo = module.get(getRepositoryToken(ActivityEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('getDashboardSummary', () => {
|
||||
it('should return dashboard summary with all stats', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
setParameter: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({
|
||||
total: 10,
|
||||
totalValue: 100000,
|
||||
wonCount: 3,
|
||||
wonValue: 45000,
|
||||
}),
|
||||
};
|
||||
|
||||
leadRepo.count
|
||||
.mockResolvedValueOnce(50) // total leads
|
||||
.mockResolvedValueOnce(10); // converted leads
|
||||
opportunityRepo.count.mockResolvedValue(20);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
activityRepo.count.mockResolvedValue(25);
|
||||
|
||||
const result = await service.getDashboardSummary(mockTenantId);
|
||||
|
||||
expect(result.totalLeads).toBe(50);
|
||||
expect(result.totalOpportunities).toBe(20);
|
||||
expect(result.totalPipelineValue).toBe(100000);
|
||||
expect(result.wonDeals).toBe(3);
|
||||
expect(result.conversionRate).toBe(20);
|
||||
expect(result.averageDealSize).toBe(15000);
|
||||
expect(result.activitiesThisWeek).toBe(25);
|
||||
expect(result.currency).toBe('USD');
|
||||
});
|
||||
|
||||
it('should handle zero leads gracefully', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
setParameter: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({
|
||||
total: 0,
|
||||
totalValue: 0,
|
||||
wonCount: 0,
|
||||
wonValue: 0,
|
||||
}),
|
||||
};
|
||||
|
||||
leadRepo.count.mockResolvedValue(0);
|
||||
opportunityRepo.count.mockResolvedValue(0);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
activityRepo.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.getDashboardSummary(mockTenantId);
|
||||
|
||||
expect(result.totalLeads).toBe(0);
|
||||
expect(result.conversionRate).toBe(0);
|
||||
expect(result.averageDealSize).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeadsByStatus', () => {
|
||||
it('should return leads grouped by status', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ status: LeadStatus.NEW, count: 20 },
|
||||
{ status: LeadStatus.CONTACTED, count: 15 },
|
||||
{ status: LeadStatus.QUALIFIED, count: 10 },
|
||||
{ status: LeadStatus.CONVERTED, count: 5 },
|
||||
]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getLeadsByStatus(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0].status).toBe(LeadStatus.NEW);
|
||||
expect(result[0].count).toBe(20);
|
||||
expect(result[0].percentage).toBe(40); // 20/50 = 40%
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getLeadsByStatus(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getLeadsBySource', () => {
|
||||
it('should return leads grouped by source', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ source: 'website', count: 30 },
|
||||
{ source: 'referral', count: 15 },
|
||||
{ source: 'social', count: 5 },
|
||||
]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getLeadsBySource(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].source).toBe('website');
|
||||
expect(result[0].count).toBe(30);
|
||||
expect(result[0].percentage).toBe(60); // 30/50 = 60%
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOpportunitiesByStage', () => {
|
||||
it('should return opportunities grouped by stage', async () => {
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
addSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
groupBy: jest.fn().mockReturnThis(),
|
||||
getRawMany: jest.fn().mockResolvedValue([
|
||||
{ stage: OpportunityStage.QUALIFICATION, count: 10, totalAmount: 100000 },
|
||||
{ stage: OpportunityStage.PROPOSAL, count: 5, totalAmount: 75000 },
|
||||
{ stage: OpportunityStage.CLOSED_WON, count: 3, totalAmount: 45000 },
|
||||
]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getOpportunitiesByStage(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result[0].stage).toBe(OpportunityStage.QUALIFICATION);
|
||||
expect(result[0].count).toBe(10);
|
||||
expect(result[0].totalAmount).toBe(100000);
|
||||
expect(result[0].percentage).toBe(56); // 10/18 ≈ 56%
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConversionFunnel', () => {
|
||||
it('should return conversion funnel stages', async () => {
|
||||
leadRepo.count
|
||||
.mockResolvedValueOnce(100) // total leads
|
||||
.mockResolvedValueOnce(40) // new leads
|
||||
.mockResolvedValueOnce(30) // contacted
|
||||
.mockResolvedValueOnce(20) // qualified
|
||||
.mockResolvedValueOnce(10); // converted
|
||||
|
||||
opportunityRepo.count.mockResolvedValue(5); // won
|
||||
|
||||
const result = await service.getConversionFunnel(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result[0].stage).toBe('New Leads');
|
||||
expect(result[0].count).toBe(40);
|
||||
expect(result[4].stage).toBe('Won Deals');
|
||||
expect(result[4].count).toBe(5);
|
||||
});
|
||||
|
||||
it('should handle zero leads in funnel', async () => {
|
||||
leadRepo.count.mockResolvedValue(0);
|
||||
opportunityRepo.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.getConversionFunnel(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
result.forEach((stage) => {
|
||||
expect(stage.percentage).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSalesPerformance', () => {
|
||||
it('should return sales performance by user', async () => {
|
||||
dataSource.query.mockResolvedValue([
|
||||
{
|
||||
user_id: 'user-001',
|
||||
user_name: 'John Doe',
|
||||
leads_assigned: 20,
|
||||
opportunities_won: 5,
|
||||
total_revenue: 50000,
|
||||
activities_completed: 30,
|
||||
},
|
||||
{
|
||||
user_id: 'user-002',
|
||||
user_name: 'Jane Smith',
|
||||
leads_assigned: 15,
|
||||
opportunities_won: 3,
|
||||
total_revenue: 35000,
|
||||
activities_completed: 25,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getSalesPerformance(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].userId).toBe('user-001');
|
||||
expect(result[0].userName).toBe('John Doe');
|
||||
expect(result[0].leadsAssigned).toBe(20);
|
||||
expect(result[0].opportunitiesWon).toBe(5);
|
||||
expect(result[0].totalRevenue).toBe(50000);
|
||||
expect(result[0].conversionRate).toBe(25); // 5/20 = 25%
|
||||
expect(result[0].activitiesCompleted).toBe(30);
|
||||
});
|
||||
|
||||
it('should handle empty results', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getSalesPerformance(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle user with zero leads', async () => {
|
||||
dataSource.query.mockResolvedValue([
|
||||
{
|
||||
user_id: 'user-001',
|
||||
user_name: 'New User',
|
||||
leads_assigned: 0,
|
||||
opportunities_won: 0,
|
||||
total_revenue: 0,
|
||||
activities_completed: 0,
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await service.getSalesPerformance(mockTenantId);
|
||||
|
||||
expect(result[0].conversionRate).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user