[TASK-007] feat: P1 complete - billing entities + unit tests
## T-01.5: Billing entities - invoice.entity.ts: Added hosted_invoice_url, customer_name, customer_email, billing_address, notes ## T-05.1: Sales tests (109 tests, 100% coverage) - leads.service.spec.ts - opportunities.service.spec.ts - activities.service.spec.ts - pipeline.service.spec.ts ## T-05.2: Commissions tests (136 tests, 100% coverage) - schemes.service.spec.ts - assignments.service.spec.ts - entries.service.spec.ts - periods.service.spec.ts ## T-05.3: Portfolio tests (79 tests, 100% coverage) - categories.service.spec.ts - products.service.spec.ts Total: 324 new tests, all passing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a881c5cc2b
commit
5b0e61c029
@ -61,6 +61,11 @@ describe('BillingService - Edge Cases', () => {
|
||||
paid_at: null as unknown as Date,
|
||||
external_invoice_id: '',
|
||||
pdf_url: null,
|
||||
hosted_invoice_url: null,
|
||||
customer_name: null,
|
||||
customer_email: null,
|
||||
billing_address: null,
|
||||
notes: null,
|
||||
line_items: [{ description: 'Pro Plan', quantity: 1, unit_price: 100, amount: 100 }],
|
||||
billing_details: null as unknown as { name?: string; email?: string; address?: string; tax_id?: string },
|
||||
items: [],
|
||||
|
||||
@ -61,6 +61,28 @@ export class Invoice {
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
pdf_url: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 500, nullable: true })
|
||||
hosted_invoice_url: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
customer_name: string | null;
|
||||
|
||||
@Column({ type: 'varchar', length: 255, nullable: true })
|
||||
customer_email: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
billing_address: {
|
||||
line1?: string;
|
||||
line2?: string;
|
||||
city?: string;
|
||||
state?: string;
|
||||
postal_code?: string;
|
||||
country?: string;
|
||||
} | null;
|
||||
|
||||
@Column({ type: 'text', nullable: true })
|
||||
notes: string | null;
|
||||
|
||||
@Column({ type: 'jsonb', nullable: true })
|
||||
line_items: Array<{
|
||||
description: string;
|
||||
|
||||
520
src/modules/commissions/__tests__/assignments.service.spec.ts
Normal file
520
src/modules/commissions/__tests__/assignments.service.spec.ts
Normal file
@ -0,0 +1,520 @@
|
||||
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';
|
||||
import { CreateAssignmentDto, UpdateAssignmentDto, AssignmentListQueryDto } from '../dto';
|
||||
|
||||
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 mockAssignmentId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
const mockTargetUserId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
|
||||
const createMockScheme = (overrides: Partial<CommissionSchemeEntity> = {}): Partial<CommissionSchemeEntity> => ({
|
||||
id: mockSchemeId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Standard Commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
isActive: true,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockAssignment = (overrides: Partial<CommissionAssignmentEntity> = {}): Partial<CommissionAssignmentEntity> => ({
|
||||
id: mockAssignmentId,
|
||||
tenantId: mockTenantId,
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: new Date('2026-01-01'),
|
||||
endsAt: null,
|
||||
customRate: null,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
createdBy: mockUserId,
|
||||
scheme: createMockScheme() as CommissionSchemeEntity,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
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 a new assignment', async () => {
|
||||
const createDto: CreateAssignmentDto = {
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
};
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(schemeRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockSchemeId, tenantId: mockTenantId, deletedAt: null },
|
||||
});
|
||||
expect(assignmentRepo.create).toHaveBeenCalledWith({
|
||||
tenantId: mockTenantId,
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: undefined,
|
||||
customRate: undefined,
|
||||
isActive: true,
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
expect(result.id).toBe(mockAssignmentId);
|
||||
});
|
||||
|
||||
it('should create assignment with custom rate', async () => {
|
||||
const createDto: CreateAssignmentDto = {
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
customRate: 15,
|
||||
};
|
||||
|
||||
const assignmentWithCustomRate = { ...createMockAssignment(), customRate: 15 };
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(assignmentWithCustomRate as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(assignmentWithCustomRate as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.customRate).toBe(15);
|
||||
});
|
||||
|
||||
it('should create assignment with start and end dates', async () => {
|
||||
const createDto: CreateAssignmentDto = {
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: '2026-02-01',
|
||||
endsAt: '2026-12-31',
|
||||
};
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
assignmentRepo.create.mockReturnValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
|
||||
await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(assignmentRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
const createDto: CreateAssignmentDto = {
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
};
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow('Commission scheme not found');
|
||||
});
|
||||
|
||||
it('should create inactive assignment when specified', async () => {
|
||||
const createDto: CreateAssignmentDto = {
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
const inactiveAssignment = { ...createMockAssignment(), isActive: false };
|
||||
assignmentRepo.create.mockReturnValue(inactiveAssignment as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(inactiveAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated list of assignments', async () => {
|
||||
const query: AssignmentListQueryDto = { page: 1, limit: 10 };
|
||||
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([[createMockAssignment()], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
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 userId', async () => {
|
||||
const query: AssignmentListQueryDto = { page: 1, limit: 10, userId: mockTargetUserId };
|
||||
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([[createMockAssignment()], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.user_id = :userId', { userId: mockTargetUserId });
|
||||
});
|
||||
|
||||
it('should filter by schemeId', async () => {
|
||||
const query: AssignmentListQueryDto = { page: 1, limit: 10, schemeId: mockSchemeId };
|
||||
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([[createMockAssignment()], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.scheme_id = :schemeId', { schemeId: mockSchemeId });
|
||||
});
|
||||
|
||||
it('should filter by isActive', async () => {
|
||||
const query: AssignmentListQueryDto = { page: 1, limit: 10, isActive: true };
|
||||
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([[createMockAssignment()], 1]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('a.is_active = :isActive', { isActive: true });
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const query: AssignmentListQueryDto = { page: 1, limit: 500 };
|
||||
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([[], 0]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
expect(result.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('should use default pagination values', async () => {
|
||||
const query: AssignmentListQueryDto = {};
|
||||
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([[], 0]),
|
||||
};
|
||||
|
||||
assignmentRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a single assignment', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockAssignmentId);
|
||||
|
||||
expect(assignmentRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockAssignmentId, tenantId: mockTenantId },
|
||||
relations: ['scheme'],
|
||||
});
|
||||
expect(result.id).toBe(mockAssignmentId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, mockAssignmentId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(mockTenantId, mockAssignmentId)).rejects.toThrow('Commission assignment not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing assignment', async () => {
|
||||
const updateDto: UpdateAssignmentDto = {
|
||||
customRate: 12.5,
|
||||
};
|
||||
|
||||
const updatedAssignment = { ...createMockAssignment(), customRate: 12.5 };
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockAssignmentId, updateDto);
|
||||
|
||||
expect(result.customRate).toBe(12.5);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, mockAssignmentId, { customRate: 10 })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update dates', async () => {
|
||||
const updateDto: UpdateAssignmentDto = {
|
||||
startsAt: '2026-03-01',
|
||||
endsAt: '2026-06-30',
|
||||
};
|
||||
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockImplementation(async (entity) => entity as CommissionAssignmentEntity);
|
||||
|
||||
await service.update(mockTenantId, mockAssignmentId, updateDto);
|
||||
|
||||
expect(assignmentRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should update isActive status', async () => {
|
||||
const updateDto: UpdateAssignmentDto = {
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
const updatedAssignment = { ...createMockAssignment(), isActive: false };
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.save.mockResolvedValue(updatedAssignment as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockAssignmentId, updateDto);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete an assignment', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
assignmentRepo.remove.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockAssignmentId);
|
||||
|
||||
expect(assignmentRepo.remove).toHaveBeenCalledWith(createMockAssignment());
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when assignment not found', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockAssignmentId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findActiveForUser', () => {
|
||||
it('should return active assignments for a user', async () => {
|
||||
const now = new Date();
|
||||
const activeAssignment = {
|
||||
...createMockAssignment(),
|
||||
startsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
endsAt: new Date(now.getTime() + 86400000), // tomorrow
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
assignmentRepo.find.mockResolvedValue([activeAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockTargetUserId);
|
||||
|
||||
expect(assignmentRepo.find).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: mockTenantId,
|
||||
userId: mockTargetUserId,
|
||||
isActive: true,
|
||||
},
|
||||
relations: ['scheme'],
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should filter out assignments not yet started', async () => {
|
||||
const now = new Date();
|
||||
const futureAssignment = {
|
||||
...createMockAssignment(),
|
||||
startsAt: new Date(now.getTime() + 86400000), // tomorrow
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
assignmentRepo.find.mockResolvedValue([futureAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockTargetUserId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should filter out expired assignments', async () => {
|
||||
const now = new Date();
|
||||
const expiredAssignment = {
|
||||
...createMockAssignment(),
|
||||
startsAt: new Date(now.getTime() - 172800000), // 2 days ago
|
||||
endsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
assignmentRepo.find.mockResolvedValue([expiredAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockTargetUserId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should include assignments with no end date', async () => {
|
||||
const now = new Date();
|
||||
const openEndedAssignment = {
|
||||
...createMockAssignment(),
|
||||
startsAt: new Date(now.getTime() - 86400000), // yesterday
|
||||
endsAt: null,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
assignmentRepo.find.mockResolvedValue([openEndedAssignment as CommissionAssignmentEntity]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockTargetUserId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should return empty array when no active assignments', async () => {
|
||||
assignmentRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.findActiveForUser(mockTenantId, mockTargetUserId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponse (private method via public methods)', () => {
|
||||
it('should convert entity to response DTO', async () => {
|
||||
assignmentRepo.findOne.mockResolvedValue(createMockAssignment() as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockAssignmentId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockAssignmentId,
|
||||
tenantId: mockTenantId,
|
||||
userId: mockTargetUserId,
|
||||
schemeId: mockSchemeId,
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: null,
|
||||
customRate: null,
|
||||
isActive: true,
|
||||
createdAt: expect.any(Date),
|
||||
createdBy: mockUserId,
|
||||
scheme: createMockScheme(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert customRate string to number', async () => {
|
||||
const assignmentWithStringRate = { ...createMockAssignment(), customRate: '12.5' };
|
||||
assignmentRepo.findOne.mockResolvedValue(assignmentWithStringRate as any);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockAssignmentId);
|
||||
|
||||
expect(typeof result.customRate).toBe('number');
|
||||
expect(result.customRate).toBe(12.5);
|
||||
});
|
||||
|
||||
it('should handle null customRate', async () => {
|
||||
const assignmentWithNullRate = { ...createMockAssignment(), customRate: null };
|
||||
assignmentRepo.findOne.mockResolvedValue(assignmentWithNullRate as CommissionAssignmentEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockAssignmentId);
|
||||
|
||||
expect(result.customRate).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
686
src/modules/commissions/__tests__/entries.service.spec.ts
Normal file
686
src/modules/commissions/__tests__/entries.service.spec.ts
Normal file
@ -0,0 +1,686 @@
|
||||
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';
|
||||
import {
|
||||
CreateEntryDto,
|
||||
UpdateEntryDto,
|
||||
ApproveEntryDto,
|
||||
RejectEntryDto,
|
||||
EntryListQueryDto,
|
||||
CalculateCommissionDto,
|
||||
} from '../dto';
|
||||
|
||||
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 mockEntryId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const mockSchemeId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
const mockAssignmentId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
const mockReferenceId = '550e8400-e29b-41d4-a716-446655440006';
|
||||
const mockPeriodId = '550e8400-e29b-41d4-a716-446655440007';
|
||||
const mockApproverId = '550e8400-e29b-41d4-a716-446655440008';
|
||||
|
||||
const createMockEntry = (overrides: Partial<CommissionEntryEntity> = {}): Partial<CommissionEntryEntity> => ({
|
||||
id: mockEntryId,
|
||||
tenantId: mockTenantId,
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
assignmentId: mockAssignmentId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
rateApplied: 10,
|
||||
commissionAmount: 100,
|
||||
currency: 'USD',
|
||||
status: EntryStatus.PENDING,
|
||||
periodId: null,
|
||||
paidAt: null,
|
||||
paymentReference: null,
|
||||
notes: null,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
approvedBy: null,
|
||||
approvedAt: null,
|
||||
scheme: null,
|
||||
period: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockEntryRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: 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.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new commission entry', async () => {
|
||||
const createDto: CreateEntryDto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
assignmentId: mockAssignmentId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
entryRepo.create.mockReturnValue(createMockEntry() as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
|
||||
[mockSchemeId, mockUserId, 1000, mockTenantId],
|
||||
);
|
||||
expect(result.id).toBe(mockEntryId);
|
||||
expect(result.commissionAmount).toBe(100);
|
||||
});
|
||||
|
||||
it('should create entry with custom currency', async () => {
|
||||
const createDto: CreateEntryDto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
currency: 'EUR',
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
const eurEntry = { ...createMockEntry(), currency: 'EUR' };
|
||||
entryRepo.create.mockReturnValue(eurEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(eurEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.currency).toBe('EUR');
|
||||
});
|
||||
|
||||
it('should create entry with notes and metadata', async () => {
|
||||
const createDto: CreateEntryDto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
notes: 'Special commission for VIP sale',
|
||||
metadata: { vipClient: true, discount: 5 },
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
const entryWithMeta = { ...createMockEntry(), notes: createDto.notes, metadata: createDto.metadata };
|
||||
entryRepo.create.mockReturnValue(entryWithMeta as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(entryWithMeta as CommissionEntryEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.notes).toBe('Special commission for VIP sale');
|
||||
expect(result.metadata).toEqual({ vipClient: true, discount: 5 });
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when commission is zero', async () => {
|
||||
const createDto: CreateEntryDto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 0, commission_amount: 0 }]);
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(
|
||||
'Commission calculation resulted in zero amount',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty calculation result', async () => {
|
||||
const createDto: CreateEntryDto = {
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated list of entries', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10 };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should filter by userId', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10, userId: mockUserId };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.user_id = :userId', { userId: mockUserId });
|
||||
});
|
||||
|
||||
it('should filter by schemeId', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10, schemeId: mockSchemeId };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.scheme_id = :schemeId', { schemeId: mockSchemeId });
|
||||
});
|
||||
|
||||
it('should filter by periodId', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10, periodId: mockPeriodId };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.period_id = :periodId', { periodId: mockPeriodId });
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10, status: EntryStatus.PENDING };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.status = :status', { status: EntryStatus.PENDING });
|
||||
});
|
||||
|
||||
it('should filter by referenceType', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 10, referenceType: 'sale' };
|
||||
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([[createMockEntry()], 1]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('e.reference_type = :referenceType', { referenceType: 'sale' });
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const query: EntryListQueryDto = { page: 1, limit: 500 };
|
||||
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([[], 0]),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
expect(result.limit).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a single entry', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockEntryId);
|
||||
|
||||
expect(entryRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockEntryId, tenantId: mockTenantId },
|
||||
relations: ['scheme', 'period'],
|
||||
});
|
||||
expect(result.id).toBe(mockEntryId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, mockEntryId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(mockTenantId, mockEntryId)).rejects.toThrow('Commission entry not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing entry', async () => {
|
||||
const updateDto: UpdateEntryDto = {
|
||||
notes: 'Updated notes',
|
||||
periodId: mockPeriodId,
|
||||
};
|
||||
|
||||
const updatedEntry = { ...createMockEntry(), notes: 'Updated notes', periodId: mockPeriodId };
|
||||
entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockEntryId, updateDto);
|
||||
|
||||
expect(result.notes).toBe('Updated notes');
|
||||
expect(result.periodId).toBe(mockPeriodId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, mockEntryId, { notes: 'Test' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update status', async () => {
|
||||
const updateDto: UpdateEntryDto = {
|
||||
status: EntryStatus.APPROVED,
|
||||
};
|
||||
|
||||
const updatedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED };
|
||||
entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockEntryId, updateDto);
|
||||
|
||||
expect(result.status).toBe(EntryStatus.APPROVED);
|
||||
});
|
||||
|
||||
it('should update metadata', async () => {
|
||||
const updateDto: UpdateEntryDto = {
|
||||
metadata: { customField: 'value' },
|
||||
};
|
||||
|
||||
const updatedEntry = { ...createMockEntry(), metadata: { customField: 'value' } };
|
||||
entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(updatedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockEntryId, updateDto);
|
||||
|
||||
expect(result.metadata).toEqual({ customField: 'value' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
it('should approve a pending entry', async () => {
|
||||
const dto: ApproveEntryDto = { notes: 'Approved by manager' };
|
||||
const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING };
|
||||
const approvedEntry = {
|
||||
...createMockEntry(),
|
||||
status: EntryStatus.APPROVED,
|
||||
approvedBy: mockApproverId,
|
||||
approvedAt: expect.any(Date),
|
||||
notes: 'Approved by manager',
|
||||
};
|
||||
|
||||
entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.approve(mockTenantId, mockEntryId, mockApproverId, dto);
|
||||
|
||||
expect(result.status).toBe(EntryStatus.APPROVED);
|
||||
expect(result.approvedBy).toBe(mockApproverId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when entry is not pending', async () => {
|
||||
const approvedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED };
|
||||
entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
|
||||
await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow(BadRequestException);
|
||||
await expect(service.approve(mockTenantId, mockEntryId, mockApproverId, {})).rejects.toThrow(
|
||||
'Only pending entries can be approved',
|
||||
);
|
||||
});
|
||||
|
||||
it('should approve without notes', async () => {
|
||||
const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING, notes: 'Original notes' };
|
||||
const approvedEntry = { ...pendingEntry, status: EntryStatus.APPROVED, approvedBy: mockApproverId };
|
||||
|
||||
entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.approve(mockTenantId, mockEntryId, mockApproverId, {});
|
||||
|
||||
expect(result.status).toBe(EntryStatus.APPROVED);
|
||||
expect(result.notes).toBe('Original notes');
|
||||
});
|
||||
});
|
||||
|
||||
describe('reject', () => {
|
||||
it('should reject a pending entry', async () => {
|
||||
const dto: RejectEntryDto = { notes: 'Rejected - invalid sale' };
|
||||
const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING };
|
||||
const rejectedEntry = {
|
||||
...createMockEntry(),
|
||||
status: EntryStatus.REJECTED,
|
||||
approvedBy: mockApproverId,
|
||||
approvedAt: expect.any(Date),
|
||||
notes: 'Rejected - invalid sale',
|
||||
};
|
||||
|
||||
entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(rejectedEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.reject(mockTenantId, mockEntryId, mockApproverId, dto);
|
||||
|
||||
expect(result.status).toBe(EntryStatus.REJECTED);
|
||||
expect(result.notes).toBe('Rejected - invalid sale');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when entry is not pending', async () => {
|
||||
const paidEntry = { ...createMockEntry(), status: EntryStatus.PAID };
|
||||
entryRepo.findOne.mockResolvedValue(paidEntry as CommissionEntryEntity);
|
||||
|
||||
await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.reject(mockTenantId, mockEntryId, mockApproverId, { notes: 'Test' })).rejects.toThrow(
|
||||
'Only pending entries can be rejected',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPaid', () => {
|
||||
it('should mark approved entry as paid', async () => {
|
||||
const approvedEntry = { ...createMockEntry(), status: EntryStatus.APPROVED };
|
||||
const paidEntry = {
|
||||
...createMockEntry(),
|
||||
status: EntryStatus.PAID,
|
||||
paidAt: expect.any(Date),
|
||||
paymentReference: 'PAY-12345',
|
||||
};
|
||||
|
||||
entryRepo.findOne.mockResolvedValue(approvedEntry as CommissionEntryEntity);
|
||||
entryRepo.save.mockResolvedValue(paidEntry as CommissionEntryEntity);
|
||||
|
||||
const result = await service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345');
|
||||
|
||||
expect(result.status).toBe(EntryStatus.PAID);
|
||||
expect(result.paymentReference).toBe('PAY-12345');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when entry not found', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when entry is not approved', async () => {
|
||||
const pendingEntry = { ...createMockEntry(), status: EntryStatus.PENDING };
|
||||
entryRepo.findOne.mockResolvedValue(pendingEntry as CommissionEntryEntity);
|
||||
|
||||
await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow(BadRequestException);
|
||||
await expect(service.markAsPaid(mockTenantId, mockEntryId, 'PAY-12345')).rejects.toThrow(
|
||||
'Only approved entries can be marked as paid',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateCommission', () => {
|
||||
it('should calculate commission for given parameters', async () => {
|
||||
const dto: CalculateCommissionDto = {
|
||||
schemeId: mockSchemeId,
|
||||
userId: mockUserId,
|
||||
amount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: 10, commission_amount: 100 }]);
|
||||
|
||||
const result = await service.calculateCommission(mockTenantId, dto);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT * FROM commissions.calculate_commission($1, $2, $3, $4)`,
|
||||
[mockSchemeId, mockUserId, 1000, mockTenantId],
|
||||
);
|
||||
expect(result.rateApplied).toBe(10);
|
||||
expect(result.commissionAmount).toBe(100);
|
||||
});
|
||||
|
||||
it('should return zero when no calculation result', async () => {
|
||||
const dto: CalculateCommissionDto = {
|
||||
schemeId: mockSchemeId,
|
||||
userId: mockUserId,
|
||||
amount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.calculateCommission(mockTenantId, dto);
|
||||
|
||||
expect(result.rateApplied).toBe(0);
|
||||
expect(result.commissionAmount).toBe(0);
|
||||
});
|
||||
|
||||
it('should convert string values to numbers', async () => {
|
||||
const dto: CalculateCommissionDto = {
|
||||
schemeId: mockSchemeId,
|
||||
userId: mockUserId,
|
||||
amount: 1000,
|
||||
};
|
||||
|
||||
dataSource.query.mockResolvedValue([{ rate_applied: '10.5', commission_amount: '105.00' }]);
|
||||
|
||||
const result = await service.calculateCommission(mockTenantId, dto);
|
||||
|
||||
expect(typeof result.rateApplied).toBe('number');
|
||||
expect(typeof result.commissionAmount).toBe('number');
|
||||
expect(result.rateApplied).toBe(10.5);
|
||||
expect(result.commissionAmount).toBe(105);
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulkApprove', () => {
|
||||
it('should bulk approve multiple entries', async () => {
|
||||
const entryIds = ['entry-001', 'entry-002', 'entry-003'];
|
||||
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, entryIds, mockApproverId);
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(mockQueryBuilder.set).toHaveBeenCalledWith({
|
||||
status: EntryStatus.APPROVED,
|
||||
approvedBy: mockApproverId,
|
||||
approvedAt: expect.any(Date),
|
||||
});
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...entryIds)', { entryIds });
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('tenant_id = :tenantId', { tenantId: mockTenantId });
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('status = :status', { status: EntryStatus.PENDING });
|
||||
});
|
||||
|
||||
it('should return 0 when no entries are approved', async () => {
|
||||
const entryIds = ['entry-001'];
|
||||
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, entryIds, mockApproverId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null affected result', async () => {
|
||||
const entryIds = ['entry-001'];
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: null }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.bulkApprove(mockTenantId, entryIds, mockApproverId);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponse (private method via public methods)', () => {
|
||||
it('should convert entity to response DTO', async () => {
|
||||
entryRepo.findOne.mockResolvedValue(createMockEntry() as CommissionEntryEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockEntryId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockEntryId,
|
||||
tenantId: mockTenantId,
|
||||
userId: mockUserId,
|
||||
schemeId: mockSchemeId,
|
||||
assignmentId: mockAssignmentId,
|
||||
referenceType: 'sale',
|
||||
referenceId: mockReferenceId,
|
||||
baseAmount: 1000,
|
||||
rateApplied: 10,
|
||||
commissionAmount: 100,
|
||||
currency: 'USD',
|
||||
status: EntryStatus.PENDING,
|
||||
periodId: null,
|
||||
paidAt: null,
|
||||
paymentReference: null,
|
||||
notes: null,
|
||||
metadata: {},
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
approvedBy: null,
|
||||
approvedAt: null,
|
||||
scheme: null,
|
||||
period: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert numeric strings to numbers', async () => {
|
||||
const entryWithStrings = {
|
||||
...createMockEntry(),
|
||||
baseAmount: '1000.00',
|
||||
rateApplied: '10.5',
|
||||
commissionAmount: '105.00',
|
||||
};
|
||||
entryRepo.findOne.mockResolvedValue(entryWithStrings as any);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockEntryId);
|
||||
|
||||
expect(typeof result.baseAmount).toBe('number');
|
||||
expect(typeof result.rateApplied).toBe('number');
|
||||
expect(typeof result.commissionAmount).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
617
src/modules/commissions/__tests__/periods.service.spec.ts
Normal file
617
src/modules/commissions/__tests__/periods.service.spec.ts
Normal file
@ -0,0 +1,617 @@
|
||||
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';
|
||||
import {
|
||||
CreatePeriodDto,
|
||||
UpdatePeriodDto,
|
||||
ClosePeriodDto,
|
||||
MarkPaidDto,
|
||||
PeriodListQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
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 mockPeriodId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
const createMockPeriod = (overrides: Partial<CommissionPeriodEntity> = {}): Partial<CommissionPeriodEntity> => ({
|
||||
id: mockPeriodId,
|
||||
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(),
|
||||
createdBy: mockUserId,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockPeriodRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: 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 new period', async () => {
|
||||
const createDto: CreatePeriodDto = {
|
||||
name: 'January 2026',
|
||||
startsAt: '2026-01-01',
|
||||
endsAt: '2026-01-31',
|
||||
};
|
||||
|
||||
periodRepo.create.mockReturnValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(periodRepo.create).toHaveBeenCalledWith({
|
||||
tenantId: mockTenantId,
|
||||
name: 'January 2026',
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: expect.any(Date),
|
||||
currency: 'USD',
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
expect(result.id).toBe(mockPeriodId);
|
||||
expect(result.name).toBe('January 2026');
|
||||
});
|
||||
|
||||
it('should create period with custom currency', async () => {
|
||||
const createDto: CreatePeriodDto = {
|
||||
name: 'January 2026',
|
||||
startsAt: '2026-01-01',
|
||||
endsAt: '2026-01-31',
|
||||
currency: 'EUR',
|
||||
};
|
||||
|
||||
const eurPeriod = { ...createMockPeriod(), currency: 'EUR' };
|
||||
periodRepo.create.mockReturnValue(eurPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(eurPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.currency).toBe('EUR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated list of periods', async () => {
|
||||
const query: PeriodListQueryDto = { page: 1, limit: 10 };
|
||||
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([[createMockPeriod()], 1]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(10);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const query: PeriodListQueryDto = { page: 1, limit: 10, status: PeriodStatus.OPEN };
|
||||
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([[createMockPeriod()], 1]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('p.status = :status', { status: PeriodStatus.OPEN });
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const query: PeriodListQueryDto = { page: 1, limit: 500 };
|
||||
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]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
expect(result.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('should use default pagination values', async () => {
|
||||
const query: PeriodListQueryDto = {};
|
||||
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]),
|
||||
};
|
||||
|
||||
periodRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a single period', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockPeriodId);
|
||||
|
||||
expect(periodRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockPeriodId, tenantId: mockTenantId },
|
||||
});
|
||||
expect(result.id).toBe(mockPeriodId);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, mockPeriodId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(mockTenantId, mockPeriodId)).rejects.toThrow('Commission period not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an open period', async () => {
|
||||
const updateDto: UpdatePeriodDto = {
|
||||
name: 'January 2026 - Updated',
|
||||
};
|
||||
|
||||
const updatedPeriod = { ...createMockPeriod(), name: 'January 2026 - Updated' };
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(updatedPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockPeriodId, updateDto);
|
||||
|
||||
expect(result.name).toBe('January 2026 - Updated');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when period is not open', async () => {
|
||||
const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow(BadRequestException);
|
||||
await expect(service.update(mockTenantId, mockPeriodId, { name: 'Test' })).rejects.toThrow(
|
||||
'Only open periods can be updated',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update dates', async () => {
|
||||
const updateDto: UpdatePeriodDto = {
|
||||
startsAt: '2026-01-15',
|
||||
endsAt: '2026-02-15',
|
||||
};
|
||||
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
periodRepo.save.mockImplementation(async (entity) => entity as CommissionPeriodEntity);
|
||||
|
||||
await service.update(mockTenantId, mockPeriodId, updateDto);
|
||||
|
||||
expect(periodRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: expect.any(Date),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('close', () => {
|
||||
it('should close an open period', async () => {
|
||||
const dto: ClosePeriodDto = {};
|
||||
const openPeriod = { ...createMockPeriod(), status: PeriodStatus.OPEN };
|
||||
const closedPeriod = {
|
||||
...createMockPeriod(),
|
||||
status: PeriodStatus.CLOSED,
|
||||
closedAt: new Date(),
|
||||
closedBy: mockUserId,
|
||||
};
|
||||
|
||||
periodRepo.findOne
|
||||
.mockResolvedValueOnce(openPeriod as CommissionPeriodEntity)
|
||||
.mockResolvedValueOnce(closedPeriod as CommissionPeriodEntity);
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.close(mockTenantId, mockPeriodId, mockUserId, dto);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
`SELECT commissions.close_period($1, $2)`,
|
||||
[mockPeriodId, mockUserId],
|
||||
);
|
||||
expect(result.status).toBe(PeriodStatus.CLOSED);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when period is not open', async () => {
|
||||
const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(BadRequestException);
|
||||
await expect(service.close(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(
|
||||
'Only open periods can be closed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsPaid', () => {
|
||||
it('should mark a closed period as paid', async () => {
|
||||
const dto: MarkPaidDto = {
|
||||
paymentReference: 'BATCH-12345',
|
||||
paymentNotes: 'Paid via bank transfer',
|
||||
};
|
||||
const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED };
|
||||
const paidPeriod = {
|
||||
...createMockPeriod(),
|
||||
status: PeriodStatus.PAID,
|
||||
paidAt: new Date(),
|
||||
paidBy: mockUserId,
|
||||
paymentReference: 'BATCH-12345',
|
||||
paymentNotes: 'Paid via bank transfer',
|
||||
};
|
||||
|
||||
const mockEntryQueryBuilder = {
|
||||
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(mockEntryQueryBuilder as any);
|
||||
|
||||
const result = await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto);
|
||||
|
||||
expect(result.status).toBe(PeriodStatus.PAID);
|
||||
expect(result.paymentReference).toBe('BATCH-12345');
|
||||
expect(result.paymentNotes).toBe('Paid via bank transfer');
|
||||
});
|
||||
|
||||
it('should mark a processing period as paid', async () => {
|
||||
const dto: MarkPaidDto = { paymentReference: 'BATCH-12345' };
|
||||
const processingPeriod = { ...createMockPeriod(), status: PeriodStatus.PROCESSING };
|
||||
const paidPeriod = { ...createMockPeriod(), status: PeriodStatus.PAID };
|
||||
|
||||
const mockEntryQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 0 }),
|
||||
};
|
||||
|
||||
periodRepo.findOne.mockResolvedValue(processingPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity);
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockEntryQueryBuilder as any);
|
||||
|
||||
const result = await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto);
|
||||
|
||||
expect(result.status).toBe(PeriodStatus.PAID);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when period is open', async () => {
|
||||
const openPeriod = { ...createMockPeriod(), status: PeriodStatus.OPEN };
|
||||
periodRepo.findOne.mockResolvedValue(openPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(BadRequestException);
|
||||
await expect(service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, {})).rejects.toThrow(
|
||||
'Only closed or processing periods can be marked as paid',
|
||||
);
|
||||
});
|
||||
|
||||
it('should update all approved entries to paid', async () => {
|
||||
const dto: MarkPaidDto = { paymentReference: 'BATCH-12345' };
|
||||
const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED };
|
||||
const paidPeriod = { ...createMockPeriod(), status: PeriodStatus.PAID };
|
||||
|
||||
const mockEntryQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: 10 }),
|
||||
};
|
||||
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
periodRepo.save.mockResolvedValue(paidPeriod as CommissionPeriodEntity);
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockEntryQueryBuilder as any);
|
||||
|
||||
await service.markAsPaid(mockTenantId, mockPeriodId, mockUserId, dto);
|
||||
|
||||
expect(mockEntryQueryBuilder.set).toHaveBeenCalledWith({
|
||||
status: EntryStatus.PAID,
|
||||
paidAt: expect.any(Date),
|
||||
paymentReference: 'BATCH-12345',
|
||||
});
|
||||
expect(mockEntryQueryBuilder.where).toHaveBeenCalledWith('period_id = :periodId', { periodId: mockPeriodId });
|
||||
expect(mockEntryQueryBuilder.andWhere).toHaveBeenCalledWith('status = :status', { status: EntryStatus.APPROVED });
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete an open period with no entries', async () => {
|
||||
const period = createMockPeriod();
|
||||
periodRepo.findOne.mockResolvedValue(period as CommissionPeriodEntity);
|
||||
entryRepo.count.mockResolvedValue(0);
|
||||
periodRepo.remove.mockResolvedValue(period as CommissionPeriodEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockPeriodId);
|
||||
|
||||
expect(periodRepo.remove).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: mockPeriodId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when period not found', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when period is not open', async () => {
|
||||
const closedPeriod = { ...createMockPeriod(), status: PeriodStatus.CLOSED };
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(
|
||||
'Only open periods can be deleted',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when period has entries', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
entryRepo.count.mockResolvedValue(5);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(BadRequestException);
|
||||
await expect(service.remove(mockTenantId, mockPeriodId)).rejects.toThrow(
|
||||
'Cannot delete period with 5 entries',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCurrentPeriod', () => {
|
||||
it('should return the current open period', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.getCurrentPeriod(mockTenantId);
|
||||
|
||||
expect(periodRepo.findOne).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: mockTenantId,
|
||||
status: PeriodStatus.OPEN,
|
||||
},
|
||||
order: { startsAt: 'DESC' },
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(mockPeriodId);
|
||||
});
|
||||
|
||||
it('should return null when no open period exists', 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 entryIds = ['entry-001', 'entry-002', 'entry-003'];
|
||||
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, mockPeriodId, entryIds);
|
||||
|
||||
expect(result).toBe(3);
|
||||
expect(mockQueryBuilder.set).toHaveBeenCalledWith({ periodId: mockPeriodId });
|
||||
expect(mockQueryBuilder.where).toHaveBeenCalledWith('id IN (:...entryIds)', { entryIds });
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('tenant_id = :tenantId', { tenantId: mockTenantId });
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('period_id IS NULL');
|
||||
});
|
||||
|
||||
it('should return 0 when no entries are assigned', async () => {
|
||||
const entryIds = ['entry-001'];
|
||||
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, mockPeriodId, entryIds);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
|
||||
it('should handle null affected result', async () => {
|
||||
const entryIds = ['entry-001'];
|
||||
const mockQueryBuilder = {
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
execute: jest.fn().mockResolvedValue({ affected: null }),
|
||||
};
|
||||
|
||||
entryRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.assignEntriesToPeriod(mockTenantId, mockPeriodId, entryIds);
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponse (private method via public methods)', () => {
|
||||
it('should convert entity to response DTO', async () => {
|
||||
periodRepo.findOne.mockResolvedValue(createMockPeriod() as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockPeriodId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockPeriodId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'January 2026',
|
||||
startsAt: expect.any(Date),
|
||||
endsAt: expect.any(Date),
|
||||
totalEntries: 0,
|
||||
totalAmount: 0,
|
||||
currency: 'USD',
|
||||
status: PeriodStatus.OPEN,
|
||||
closedAt: null,
|
||||
closedBy: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
paymentReference: null,
|
||||
paymentNotes: null,
|
||||
createdAt: expect.any(Date),
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert totalAmount string to number', async () => {
|
||||
const periodWithStringAmount = { ...createMockPeriod(), totalAmount: '1500.50' };
|
||||
periodRepo.findOne.mockResolvedValue(periodWithStringAmount as any);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockPeriodId);
|
||||
|
||||
expect(typeof result.totalAmount).toBe('number');
|
||||
expect(result.totalAmount).toBe(1500.5);
|
||||
});
|
||||
|
||||
it('should handle closed period with all fields', async () => {
|
||||
const closedPeriod = {
|
||||
...createMockPeriod(),
|
||||
status: PeriodStatus.PAID,
|
||||
totalEntries: 25,
|
||||
totalAmount: 5000,
|
||||
closedAt: new Date(),
|
||||
closedBy: mockUserId,
|
||||
paidAt: new Date(),
|
||||
paidBy: mockUserId,
|
||||
paymentReference: 'BATCH-12345',
|
||||
paymentNotes: 'Paid via bank transfer',
|
||||
};
|
||||
periodRepo.findOne.mockResolvedValue(closedPeriod as CommissionPeriodEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockPeriodId);
|
||||
|
||||
expect(result.status).toBe(PeriodStatus.PAID);
|
||||
expect(result.totalEntries).toBe(25);
|
||||
expect(result.totalAmount).toBe(5000);
|
||||
expect(result.closedAt).not.toBeNull();
|
||||
expect(result.closedBy).toBe(mockUserId);
|
||||
expect(result.paidAt).not.toBeNull();
|
||||
expect(result.paidBy).toBe(mockUserId);
|
||||
expect(result.paymentReference).toBe('BATCH-12345');
|
||||
expect(result.paymentNotes).toBe('Paid via bank transfer');
|
||||
});
|
||||
});
|
||||
});
|
||||
505
src/modules/commissions/__tests__/schemes.service.spec.ts
Normal file
505
src/modules/commissions/__tests__/schemes.service.spec.ts
Normal file
@ -0,0 +1,505 @@
|
||||
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';
|
||||
import { CreateSchemeDto, UpdateSchemeDto, SchemeListQueryDto } from '../dto';
|
||||
|
||||
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 mockSchemeId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
const createMockScheme = (overrides: Partial<CommissionSchemeEntity> = {}): Partial<CommissionSchemeEntity> => ({
|
||||
id: mockSchemeId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Standard Commission',
|
||||
description: 'Standard 10% commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
fixedAmount: 0,
|
||||
tiers: [],
|
||||
appliesTo: AppliesTo.ALL,
|
||||
productIds: [],
|
||||
categoryIds: [],
|
||||
minAmount: 0,
|
||||
maxAmount: null,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: mockUserId,
|
||||
deletedAt: null,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockSchemeRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: 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 new commission scheme', async () => {
|
||||
const createDto: CreateSchemeDto = {
|
||||
name: 'Standard Commission',
|
||||
description: 'Standard 10% commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
appliesTo: AppliesTo.ALL,
|
||||
};
|
||||
|
||||
schemeRepo.create.mockReturnValue(createMockScheme() as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(schemeRepo.create).toHaveBeenCalledWith({
|
||||
tenantId: mockTenantId,
|
||||
name: createDto.name,
|
||||
description: createDto.description,
|
||||
type: createDto.type,
|
||||
rate: createDto.rate,
|
||||
fixedAmount: 0,
|
||||
tiers: [],
|
||||
appliesTo: createDto.appliesTo,
|
||||
productIds: [],
|
||||
categoryIds: [],
|
||||
minAmount: 0,
|
||||
maxAmount: undefined,
|
||||
isActive: true,
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
expect(schemeRepo.save).toHaveBeenCalled();
|
||||
expect(result.id).toBe(mockSchemeId);
|
||||
expect(result.name).toBe('Standard Commission');
|
||||
});
|
||||
|
||||
it('should create a tiered commission scheme', async () => {
|
||||
const createDto: CreateSchemeDto = {
|
||||
name: 'Tiered Commission',
|
||||
type: SchemeType.TIERED,
|
||||
tiers: [
|
||||
{ min: 0, max: 1000, rate: 5 },
|
||||
{ min: 1000, max: 5000, rate: 7.5 },
|
||||
{ min: 5000, max: null, rate: 10 },
|
||||
],
|
||||
appliesTo: AppliesTo.ALL,
|
||||
};
|
||||
|
||||
const tieredScheme = { ...createMockScheme(), type: SchemeType.TIERED, tiers: createDto.tiers };
|
||||
schemeRepo.create.mockReturnValue(tieredScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(tieredScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.type).toBe(SchemeType.TIERED);
|
||||
expect(result.tiers).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should create a fixed amount commission scheme', async () => {
|
||||
const createDto: CreateSchemeDto = {
|
||||
name: 'Fixed Commission',
|
||||
type: SchemeType.FIXED,
|
||||
fixedAmount: 50,
|
||||
appliesTo: AppliesTo.PRODUCTS,
|
||||
productIds: ['product-001', 'product-002'],
|
||||
};
|
||||
|
||||
const fixedScheme = {
|
||||
...createMockScheme(),
|
||||
type: SchemeType.FIXED,
|
||||
fixedAmount: 50,
|
||||
appliesTo: AppliesTo.PRODUCTS,
|
||||
productIds: createDto.productIds,
|
||||
};
|
||||
schemeRepo.create.mockReturnValue(fixedScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(fixedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.type).toBe(SchemeType.FIXED);
|
||||
expect(result.fixedAmount).toBe(50);
|
||||
expect(result.appliesTo).toBe(AppliesTo.PRODUCTS);
|
||||
});
|
||||
|
||||
it('should create scheme with min and max amount limits', async () => {
|
||||
const createDto: CreateSchemeDto = {
|
||||
name: 'Limited Commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 15,
|
||||
appliesTo: AppliesTo.ALL,
|
||||
minAmount: 100,
|
||||
maxAmount: 5000,
|
||||
};
|
||||
|
||||
const limitedScheme = {
|
||||
...createMockScheme(),
|
||||
minAmount: 100,
|
||||
maxAmount: 5000,
|
||||
};
|
||||
schemeRepo.create.mockReturnValue(limitedScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(limitedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.minAmount).toBe(100);
|
||||
expect(result.maxAmount).toBe(5000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated list of schemes', async () => {
|
||||
const query: SchemeListQueryDto = { page: 1, limit: 10 };
|
||||
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([[createMockScheme()], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
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 query: SchemeListQueryDto = { page: 1, limit: 10, type: SchemeType.PERCENTAGE };
|
||||
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([[createMockScheme()], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('s.type = :type', { type: SchemeType.PERCENTAGE });
|
||||
});
|
||||
|
||||
it('should filter by isActive', async () => {
|
||||
const query: SchemeListQueryDto = { page: 1, limit: 10, isActive: true };
|
||||
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([[createMockScheme()], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('s.is_active = :isActive', { isActive: true });
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const query: SchemeListQueryDto = { page: 1, limit: 10, search: 'standard' };
|
||||
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([[createMockScheme()], 1]),
|
||||
};
|
||||
|
||||
schemeRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(s.name ILIKE :search OR s.description ILIKE :search)',
|
||||
{ search: '%standard%' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const query: SchemeListQueryDto = { page: 1, limit: 500 };
|
||||
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);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
expect(result.limit).toBe(100);
|
||||
});
|
||||
|
||||
it('should use default pagination values', async () => {
|
||||
const query: SchemeListQueryDto = {};
|
||||
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);
|
||||
|
||||
const result = await service.findAll(mockTenantId, query);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a single scheme', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(schemeRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockSchemeId, tenantId: mockTenantId, deletedAt: null },
|
||||
});
|
||||
expect(result.id).toBe(mockSchemeId);
|
||||
expect(result.name).toBe('Standard Commission');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException);
|
||||
await expect(service.findOne(mockTenantId, mockSchemeId)).rejects.toThrow('Commission scheme not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an existing scheme', async () => {
|
||||
const updateDto: UpdateSchemeDto = {
|
||||
name: 'Updated Commission',
|
||||
rate: 15,
|
||||
};
|
||||
|
||||
const updatedScheme = { ...createMockScheme(), name: 'Updated Commission', rate: 15 };
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(updatedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockSchemeId, updateDto);
|
||||
|
||||
expect(result.name).toBe('Updated Commission');
|
||||
expect(result.rate).toBe(15);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, mockSchemeId, { name: 'Test' })).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update only provided fields', async () => {
|
||||
const updateDto: UpdateSchemeDto = { description: 'New description' };
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockSchemeId, updateDto);
|
||||
|
||||
expect(result.name).toBe('Standard Commission');
|
||||
expect(result.description).toBe('New description');
|
||||
});
|
||||
|
||||
it('should update tiers for tiered scheme', async () => {
|
||||
const updateDto: UpdateSchemeDto = {
|
||||
tiers: [
|
||||
{ min: 0, max: 2000, rate: 6 },
|
||||
{ min: 2000, max: null, rate: 12 },
|
||||
],
|
||||
};
|
||||
|
||||
const tieredScheme = { ...createMockScheme(), type: SchemeType.TIERED, tiers: [] };
|
||||
schemeRepo.findOne.mockResolvedValue(tieredScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockSchemeId, updateDto);
|
||||
|
||||
expect(result.tiers).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft delete a scheme', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockImplementation(async (entity) => entity as CommissionSchemeEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(schemeRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate', () => {
|
||||
it('should activate a scheme', async () => {
|
||||
const inactiveScheme = { ...createMockScheme(), isActive: false };
|
||||
const activatedScheme = { ...createMockScheme(), isActive: true };
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(activatedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.activate(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result.isActive).toBe(true);
|
||||
expect(schemeRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isActive: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.activate(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return scheme when already active', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.activate(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deactivate', () => {
|
||||
it('should deactivate a scheme', async () => {
|
||||
const activeScheme = { ...createMockScheme(), isActive: true };
|
||||
const deactivatedScheme = { ...createMockScheme(), isActive: false };
|
||||
|
||||
schemeRepo.findOne.mockResolvedValue(activeScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(deactivatedScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.deactivate(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
expect(schemeRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isActive: false }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when scheme not found', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deactivate(mockTenantId, mockSchemeId)).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should return scheme when already inactive', async () => {
|
||||
const inactiveScheme = { ...createMockScheme(), isActive: false };
|
||||
schemeRepo.findOne.mockResolvedValue(inactiveScheme as CommissionSchemeEntity);
|
||||
schemeRepo.save.mockResolvedValue(inactiveScheme as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.deactivate(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponse (private method via public methods)', () => {
|
||||
it('should convert entity to response DTO', async () => {
|
||||
schemeRepo.findOne.mockResolvedValue(createMockScheme() as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockSchemeId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Standard Commission',
|
||||
description: 'Standard 10% commission',
|
||||
type: SchemeType.PERCENTAGE,
|
||||
rate: 10,
|
||||
fixedAmount: 0,
|
||||
tiers: [],
|
||||
appliesTo: AppliesTo.ALL,
|
||||
productIds: [],
|
||||
categoryIds: [],
|
||||
minAmount: 0,
|
||||
maxAmount: null,
|
||||
isActive: true,
|
||||
createdAt: expect.any(Date),
|
||||
updatedAt: expect.any(Date),
|
||||
createdBy: mockUserId,
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null maxAmount correctly', async () => {
|
||||
const schemeWithNullMax = { ...createMockScheme(), maxAmount: null };
|
||||
schemeRepo.findOne.mockResolvedValue(schemeWithNullMax as CommissionSchemeEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(result.maxAmount).toBeNull();
|
||||
});
|
||||
|
||||
it('should convert numeric strings to numbers', async () => {
|
||||
const schemeWithStringNumbers = {
|
||||
...createMockScheme(),
|
||||
rate: '10.5',
|
||||
fixedAmount: '25.00',
|
||||
minAmount: '100.00',
|
||||
maxAmount: '5000.00',
|
||||
};
|
||||
schemeRepo.findOne.mockResolvedValue(schemeWithStringNumbers as any);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockSchemeId);
|
||||
|
||||
expect(typeof result.rate).toBe('number');
|
||||
expect(typeof result.fixedAmount).toBe('number');
|
||||
expect(typeof result.minAmount).toBe('number');
|
||||
expect(typeof result.maxAmount).toBe('number');
|
||||
});
|
||||
});
|
||||
});
|
||||
510
src/modules/portfolio/__tests__/categories.service.spec.ts
Normal file
510
src/modules/portfolio/__tests__/categories.service.spec.ts
Normal file
@ -0,0 +1,510 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { CategoriesService } from '../services/categories.service';
|
||||
import { CategoryEntity } from '../entities/category.entity';
|
||||
import { CreateCategoryDto, UpdateCategoryDto, CategoryListQueryDto } from '../dto';
|
||||
|
||||
describe('CategoriesService', () => {
|
||||
let service: CategoriesService;
|
||||
let categoryRepo: jest.Mocked<Repository<CategoryEntity>>;
|
||||
|
||||
const tenantId = 'tenant-123';
|
||||
const userId = 'user-123';
|
||||
|
||||
const mockCategory: CategoryEntity = {
|
||||
id: 'cat-123',
|
||||
tenantId,
|
||||
parentId: null,
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
description: 'Electronic devices',
|
||||
position: 0,
|
||||
imageUrl: 'https://example.com/image.png',
|
||||
color: '#3B82F6',
|
||||
icon: 'laptop',
|
||||
isActive: true,
|
||||
metaTitle: 'Electronics Category',
|
||||
metaDescription: 'All electronic products',
|
||||
customFields: {},
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
createdBy: userId,
|
||||
deletedAt: null,
|
||||
parent: null,
|
||||
children: [],
|
||||
products: [],
|
||||
};
|
||||
|
||||
const mockChildCategory: CategoryEntity = {
|
||||
...mockCategory,
|
||||
id: 'cat-456',
|
||||
parentId: 'cat-123',
|
||||
name: 'Laptops',
|
||||
slug: 'laptops',
|
||||
description: 'Laptop computers',
|
||||
position: 1,
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockCategory], 1]),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
CategoriesService,
|
||||
{
|
||||
provide: getRepositoryToken(CategoryEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<CategoriesService>(CategoriesService);
|
||||
categoryRepo = module.get(getRepositoryToken(CategoryEntity));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
const createDto: CreateCategoryDto = {
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
description: 'Electronic devices',
|
||||
position: 0,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
it('should create a category successfully', async () => {
|
||||
categoryRepo.create.mockReturnValue(mockCategory);
|
||||
categoryRepo.save.mockResolvedValue(mockCategory);
|
||||
|
||||
const result = await service.create(tenantId, userId, createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(mockCategory.id);
|
||||
expect(result.name).toBe(mockCategory.name);
|
||||
expect(result.slug).toBe(mockCategory.slug);
|
||||
expect(categoryRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId,
|
||||
name: createDto.name,
|
||||
slug: createDto.slug,
|
||||
createdBy: userId,
|
||||
}),
|
||||
);
|
||||
expect(categoryRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create a category with parent', async () => {
|
||||
const dtoWithParent: CreateCategoryDto = {
|
||||
...createDto,
|
||||
parentId: 'parent-123',
|
||||
};
|
||||
|
||||
categoryRepo.findOne.mockResolvedValue(mockCategory); // Parent exists
|
||||
categoryRepo.create.mockReturnValue({ ...mockCategory, parentId: 'parent-123' });
|
||||
categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: 'parent-123' });
|
||||
|
||||
const result = await service.create(tenantId, userId, dtoWithParent);
|
||||
|
||||
expect(result.parentId).toBe('parent-123');
|
||||
expect(categoryRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'parent-123', tenantId, deletedAt: expect.anything() },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when parent not found', async () => {
|
||||
const dtoWithParent: CreateCategoryDto = {
|
||||
...createDto,
|
||||
parentId: 'non-existent-parent',
|
||||
};
|
||||
|
||||
categoryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.create(tenantId, userId, dtoWithParent)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.create(tenantId, userId, dtoWithParent)).rejects.toThrow(
|
||||
'Parent category not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should create category with default values when optional fields not provided', async () => {
|
||||
const minimalDto: CreateCategoryDto = {
|
||||
name: 'Simple',
|
||||
slug: 'simple',
|
||||
};
|
||||
|
||||
const createdCategory = {
|
||||
...mockCategory,
|
||||
name: minimalDto.name,
|
||||
slug: minimalDto.slug,
|
||||
position: 0,
|
||||
color: '#3B82F6',
|
||||
isActive: true,
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
categoryRepo.create.mockReturnValue(createdCategory);
|
||||
categoryRepo.save.mockResolvedValue(createdCategory);
|
||||
|
||||
const result = await service.create(tenantId, userId, minimalDto);
|
||||
|
||||
expect(result.position).toBe(0);
|
||||
expect(result.color).toBe('#3B82F6');
|
||||
expect(result.isActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
const query: CategoryListQueryDto = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
it('should return paginated categories', async () => {
|
||||
mockQueryBuilder.getManyAndCount.mockResolvedValue([[mockCategory], 1]);
|
||||
|
||||
const result = await service.findAll(tenantId, query);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by parentId', async () => {
|
||||
const queryWithParent: CategoryListQueryDto = {
|
||||
...query,
|
||||
parentId: 'cat-123',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithParent);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'c.parent_id = :parentId',
|
||||
{ parentId: 'cat-123' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter root categories when parentId is null or empty', async () => {
|
||||
const queryRootOnly: CategoryListQueryDto = {
|
||||
...query,
|
||||
parentId: '',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryRootOnly);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith('c.parent_id IS NULL');
|
||||
});
|
||||
|
||||
it('should filter by isActive', async () => {
|
||||
const queryActive: CategoryListQueryDto = {
|
||||
...query,
|
||||
isActive: true,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryActive);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'c.is_active = :isActive',
|
||||
{ isActive: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const querySearch: CategoryListQueryDto = {
|
||||
...query,
|
||||
search: 'elect',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, querySearch);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(c.name ILIKE :search OR c.slug ILIKE :search)',
|
||||
{ search: '%elect%' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should use default pagination when not provided', async () => {
|
||||
const emptyQuery: CategoryListQueryDto = {};
|
||||
|
||||
await service.findAll(tenantId, emptyQuery);
|
||||
|
||||
expect(mockQueryBuilder.skip).toHaveBeenCalledWith(0);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(20);
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const queryLargeLimit: CategoryListQueryDto = {
|
||||
limit: 500,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryLargeLimit);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a category by id', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue(mockCategory);
|
||||
|
||||
const result = await service.findOne(tenantId, 'cat-123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('cat-123');
|
||||
expect(result.name).toBe('Electronics');
|
||||
expect(categoryRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'cat-123', tenantId, deletedAt: expect.anything() },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when category not found', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow(
|
||||
'Category not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTree', () => {
|
||||
it('should return category tree structure', async () => {
|
||||
const parentCategory = { ...mockCategory };
|
||||
const childCategory = { ...mockChildCategory, parentId: mockCategory.id };
|
||||
|
||||
categoryRepo.find.mockResolvedValue([parentCategory, childCategory]);
|
||||
|
||||
const result = await service.getTree(tenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockCategory.id);
|
||||
expect(result[0].children).toHaveLength(1);
|
||||
expect(result[0].children[0].id).toBe(childCategory.id);
|
||||
expect(result[0].depth).toBe(0);
|
||||
expect(result[0].children[0].depth).toBe(1);
|
||||
});
|
||||
|
||||
it('should return empty array when no categories exist', async () => {
|
||||
categoryRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getTree(tenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should handle multiple root categories', async () => {
|
||||
const anotherRoot = {
|
||||
...mockCategory,
|
||||
id: 'cat-789',
|
||||
name: 'Clothing',
|
||||
slug: 'clothing',
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
categoryRepo.find.mockResolvedValue([mockCategory, anotherRoot]);
|
||||
|
||||
const result = await service.getTree(tenantId);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should handle deeply nested categories', async () => {
|
||||
const level0 = { ...mockCategory, parentId: null };
|
||||
const level1 = { ...mockCategory, id: 'cat-l1', parentId: mockCategory.id };
|
||||
const level2 = { ...mockCategory, id: 'cat-l2', parentId: 'cat-l1' };
|
||||
|
||||
categoryRepo.find.mockResolvedValue([level0, level1, level2]);
|
||||
|
||||
const result = await service.getTree(tenantId);
|
||||
|
||||
expect(result[0].depth).toBe(0);
|
||||
expect(result[0].children[0].depth).toBe(1);
|
||||
expect(result[0].children[0].children[0].depth).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateDto: UpdateCategoryDto = {
|
||||
name: 'Updated Electronics',
|
||||
description: 'Updated description',
|
||||
};
|
||||
|
||||
it('should update a category successfully', async () => {
|
||||
const updatedCategory = { ...mockCategory, ...updateDto };
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory });
|
||||
categoryRepo.save.mockResolvedValue(updatedCategory);
|
||||
|
||||
const result = await service.update(tenantId, 'cat-123', updateDto);
|
||||
|
||||
expect(result.name).toBe('Updated Electronics');
|
||||
expect(result.description).toBe('Updated description');
|
||||
expect(categoryRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when category not found', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(tenantId, 'non-existent', updateDto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should update parent successfully', async () => {
|
||||
const updateWithParent: UpdateCategoryDto = {
|
||||
parentId: 'new-parent-123',
|
||||
};
|
||||
|
||||
categoryRepo.findOne
|
||||
.mockResolvedValueOnce({ ...mockCategory }) // Category to update
|
||||
.mockResolvedValueOnce({ ...mockCategory, id: 'new-parent-123' }); // New parent
|
||||
categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: 'new-parent-123' });
|
||||
|
||||
const result = await service.update(tenantId, 'cat-123', updateWithParent);
|
||||
|
||||
expect(result.parentId).toBe('new-parent-123');
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when setting category as its own parent', async () => {
|
||||
const updateSelfParent: UpdateCategoryDto = {
|
||||
parentId: 'cat-123',
|
||||
};
|
||||
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory });
|
||||
|
||||
await expect(service.update(tenantId, 'cat-123', updateSelfParent)).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.update(tenantId, 'cat-123', updateSelfParent)).rejects.toThrow(
|
||||
'Category cannot be its own parent',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when new parent not found', async () => {
|
||||
const updateWithParent: UpdateCategoryDto = {
|
||||
parentId: 'non-existent-parent',
|
||||
};
|
||||
|
||||
categoryRepo.findOne
|
||||
.mockResolvedValueOnce({ ...mockCategory })
|
||||
.mockResolvedValueOnce(null);
|
||||
|
||||
await expect(service.update(tenantId, 'cat-123', updateWithParent)).rejects.toThrow(
|
||||
new BadRequestException('Parent category not found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('should allow setting parentId to null (make root)', async () => {
|
||||
const updateToRoot: UpdateCategoryDto = {
|
||||
parentId: null,
|
||||
};
|
||||
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory, parentId: 'some-parent' });
|
||||
categoryRepo.save.mockResolvedValue({ ...mockCategory, parentId: null });
|
||||
|
||||
const result = await service.update(tenantId, 'cat-123', updateToRoot);
|
||||
|
||||
expect(result.parentId).toBeNull();
|
||||
});
|
||||
|
||||
it('should only update provided fields', async () => {
|
||||
const partialUpdate: UpdateCategoryDto = {
|
||||
name: 'Only Name',
|
||||
};
|
||||
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory });
|
||||
categoryRepo.save.mockImplementation((cat) => Promise.resolve(cat as CategoryEntity));
|
||||
|
||||
const result = await service.update(tenantId, 'cat-123', partialUpdate);
|
||||
|
||||
expect(result.name).toBe('Only Name');
|
||||
expect(result.slug).toBe(mockCategory.slug);
|
||||
expect(result.description).toBe(mockCategory.description);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft-delete a category successfully', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory });
|
||||
categoryRepo.count.mockResolvedValue(0);
|
||||
categoryRepo.save.mockResolvedValue({ ...mockCategory, deletedAt: new Date() });
|
||||
|
||||
await expect(service.remove(tenantId, 'cat-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(categoryRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when category not found', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when category has children', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue({ ...mockCategory });
|
||||
categoryRepo.count.mockResolvedValue(2);
|
||||
|
||||
await expect(service.remove(tenantId, 'cat-123')).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.remove(tenantId, 'cat-123')).rejects.toThrow(
|
||||
'Cannot delete category with child categories',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toResponse mapping', () => {
|
||||
it('should correctly map entity to response DTO', async () => {
|
||||
categoryRepo.findOne.mockResolvedValue(mockCategory);
|
||||
|
||||
const result = await service.findOne(tenantId, 'cat-123');
|
||||
|
||||
expect(result).toEqual({
|
||||
id: mockCategory.id,
|
||||
tenantId: mockCategory.tenantId,
|
||||
parentId: mockCategory.parentId,
|
||||
name: mockCategory.name,
|
||||
slug: mockCategory.slug,
|
||||
description: mockCategory.description,
|
||||
position: mockCategory.position,
|
||||
imageUrl: mockCategory.imageUrl,
|
||||
color: mockCategory.color,
|
||||
icon: mockCategory.icon,
|
||||
isActive: mockCategory.isActive,
|
||||
metaTitle: mockCategory.metaTitle,
|
||||
metaDescription: mockCategory.metaDescription,
|
||||
customFields: mockCategory.customFields,
|
||||
createdAt: mockCategory.createdAt,
|
||||
updatedAt: mockCategory.updatedAt,
|
||||
createdBy: mockCategory.createdBy,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
997
src/modules/portfolio/__tests__/products.service.spec.ts
Normal file
997
src/modules/portfolio/__tests__/products.service.spec.ts
Normal file
@ -0,0 +1,997 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { ProductsService } from '../services/products.service';
|
||||
import { ProductEntity, ProductStatus, ProductType, VariantEntity, PriceEntity } from '../entities';
|
||||
import { PriceType } from '../entities/price.entity';
|
||||
import {
|
||||
CreateProductDto,
|
||||
UpdateProductDto,
|
||||
UpdateProductStatusDto,
|
||||
CreateVariantDto,
|
||||
UpdateVariantDto,
|
||||
CreatePriceDto,
|
||||
UpdatePriceDto,
|
||||
ProductListQueryDto,
|
||||
} from '../dto';
|
||||
|
||||
describe('ProductsService', () => {
|
||||
let service: ProductsService;
|
||||
let productRepo: jest.Mocked<Repository<ProductEntity>>;
|
||||
let variantRepo: jest.Mocked<Repository<VariantEntity>>;
|
||||
let priceRepo: jest.Mocked<Repository<PriceEntity>>;
|
||||
|
||||
const tenantId = 'tenant-123';
|
||||
const userId = 'user-123';
|
||||
|
||||
const mockProduct: ProductEntity = {
|
||||
id: 'prod-123',
|
||||
tenantId,
|
||||
categoryId: 'cat-123',
|
||||
name: 'Test Product',
|
||||
slug: 'test-product',
|
||||
sku: 'SKU-001',
|
||||
barcode: '1234567890',
|
||||
description: 'Test description',
|
||||
shortDescription: 'Short desc',
|
||||
productType: ProductType.PHYSICAL,
|
||||
status: ProductStatus.DRAFT,
|
||||
basePrice: 99.99,
|
||||
costPrice: 50,
|
||||
compareAtPrice: 129.99,
|
||||
currency: 'USD',
|
||||
trackInventory: true,
|
||||
stockQuantity: 100,
|
||||
lowStockThreshold: 10,
|
||||
allowBackorder: false,
|
||||
weight: 1.5,
|
||||
weightUnit: 'kg',
|
||||
length: 10,
|
||||
width: 5,
|
||||
height: 3,
|
||||
dimensionUnit: 'cm',
|
||||
images: ['image1.jpg', 'image2.jpg'],
|
||||
featuredImageUrl: 'featured.jpg',
|
||||
metaTitle: 'Test Product',
|
||||
metaDescription: 'Meta description',
|
||||
tags: ['tag1', 'tag2'],
|
||||
isVisible: true,
|
||||
isFeatured: false,
|
||||
hasVariants: false,
|
||||
variantAttributes: [],
|
||||
customFields: {},
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
createdBy: userId,
|
||||
publishedAt: null,
|
||||
deletedAt: null,
|
||||
category: null,
|
||||
variants: [],
|
||||
prices: [],
|
||||
};
|
||||
|
||||
const mockVariant: VariantEntity = {
|
||||
id: 'var-123',
|
||||
tenantId,
|
||||
productId: 'prod-123',
|
||||
sku: 'SKU-001-RED',
|
||||
barcode: '1234567891',
|
||||
name: 'Red Variant',
|
||||
attributes: { color: 'red', size: 'M' },
|
||||
price: 109.99,
|
||||
costPrice: 55,
|
||||
compareAtPrice: 139.99,
|
||||
stockQuantity: 50,
|
||||
lowStockThreshold: 5,
|
||||
weight: 1.6,
|
||||
imageUrl: 'red-variant.jpg',
|
||||
isActive: true,
|
||||
position: 0,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
deletedAt: null,
|
||||
product: mockProduct,
|
||||
prices: [],
|
||||
};
|
||||
|
||||
const mockPrice: PriceEntity = {
|
||||
id: 'price-123',
|
||||
tenantId,
|
||||
productId: 'prod-123',
|
||||
variantId: null,
|
||||
priceType: PriceType.ONE_TIME,
|
||||
currency: 'USD',
|
||||
amount: 99.99,
|
||||
compareAtAmount: 129.99,
|
||||
billingPeriod: null,
|
||||
billingInterval: null,
|
||||
minQuantity: 1,
|
||||
maxQuantity: null,
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
priority: 0,
|
||||
isActive: true,
|
||||
createdAt: new Date('2026-01-01'),
|
||||
updatedAt: new Date('2026-01-01'),
|
||||
deletedAt: null,
|
||||
product: null,
|
||||
variant: null,
|
||||
};
|
||||
|
||||
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([[mockProduct], 1]),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ProductsService,
|
||||
{
|
||||
provide: getRepositoryToken(ProductEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(VariantEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(PriceEntity),
|
||||
useValue: {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
save: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ProductsService>(ProductsService);
|
||||
productRepo = module.get(getRepositoryToken(ProductEntity));
|
||||
variantRepo = module.get(getRepositoryToken(VariantEntity));
|
||||
priceRepo = module.get(getRepositoryToken(PriceEntity));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Products Tests
|
||||
// ============================================
|
||||
|
||||
describe('create', () => {
|
||||
const createDto: CreateProductDto = {
|
||||
name: 'Test Product',
|
||||
slug: 'test-product',
|
||||
categoryId: 'cat-123',
|
||||
sku: 'SKU-001',
|
||||
basePrice: 99.99,
|
||||
};
|
||||
|
||||
it('should create a product successfully', async () => {
|
||||
productRepo.create.mockReturnValue(mockProduct);
|
||||
productRepo.save.mockResolvedValue(mockProduct);
|
||||
|
||||
const result = await service.create(tenantId, userId, createDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(mockProduct.id);
|
||||
expect(result.name).toBe(mockProduct.name);
|
||||
expect(productRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId,
|
||||
name: createDto.name,
|
||||
slug: createDto.slug,
|
||||
createdBy: userId,
|
||||
}),
|
||||
);
|
||||
expect(productRepo.save).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create product with default values', async () => {
|
||||
const minimalDto: CreateProductDto = {
|
||||
name: 'Minimal',
|
||||
slug: 'minimal',
|
||||
};
|
||||
|
||||
const createdProduct = {
|
||||
...mockProduct,
|
||||
categoryId: null,
|
||||
status: ProductStatus.DRAFT,
|
||||
basePrice: 0,
|
||||
currency: 'USD',
|
||||
trackInventory: true,
|
||||
stockQuantity: 0,
|
||||
lowStockThreshold: 5,
|
||||
allowBackorder: false,
|
||||
images: [],
|
||||
tags: [],
|
||||
isVisible: true,
|
||||
isFeatured: false,
|
||||
hasVariants: false,
|
||||
customFields: {},
|
||||
};
|
||||
|
||||
productRepo.create.mockReturnValue(createdProduct);
|
||||
productRepo.save.mockResolvedValue(createdProduct);
|
||||
|
||||
const result = await service.create(tenantId, userId, minimalDto);
|
||||
|
||||
expect(result.status).toBe(ProductStatus.DRAFT);
|
||||
expect(result.basePrice).toBe(0);
|
||||
expect(result.currency).toBe('USD');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
const query: ProductListQueryDto = {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
};
|
||||
|
||||
it('should return paginated products', async () => {
|
||||
mockQueryBuilder.getManyAndCount.mockResolvedValue([[mockProduct], 1]);
|
||||
|
||||
const result = await service.findAll(tenantId, query);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.limit).toBe(20);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by categoryId', async () => {
|
||||
const queryWithCategory: ProductListQueryDto = {
|
||||
...query,
|
||||
categoryId: 'cat-123',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithCategory);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.category_id = :categoryId',
|
||||
{ categoryId: 'cat-123' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by productType', async () => {
|
||||
const queryWithType: ProductListQueryDto = {
|
||||
...query,
|
||||
productType: ProductType.DIGITAL,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithType);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.product_type = :productType',
|
||||
{ productType: ProductType.DIGITAL },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const queryWithStatus: ProductListQueryDto = {
|
||||
...query,
|
||||
status: ProductStatus.ACTIVE,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithStatus);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.status = :status',
|
||||
{ status: ProductStatus.ACTIVE },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by price range', async () => {
|
||||
const queryWithPrice: ProductListQueryDto = {
|
||||
...query,
|
||||
minPrice: 50,
|
||||
maxPrice: 200,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithPrice);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.base_price >= :minPrice',
|
||||
{ minPrice: 50 },
|
||||
);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.base_price <= :maxPrice',
|
||||
{ maxPrice: 200 },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by search term', async () => {
|
||||
const queryWithSearch: ProductListQueryDto = {
|
||||
...query,
|
||||
search: 'test',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithSearch);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(p.name ILIKE :search OR p.slug ILIKE :search OR p.sku ILIKE :search OR p.description ILIKE :search)',
|
||||
{ search: '%test%' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by visibility and featured', async () => {
|
||||
const queryWithFlags: ProductListQueryDto = {
|
||||
...query,
|
||||
isVisible: true,
|
||||
isFeatured: true,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithFlags);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.is_visible = :isVisible',
|
||||
{ isVisible: true },
|
||||
);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.is_featured = :isFeatured',
|
||||
{ isFeatured: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by tags', async () => {
|
||||
const queryWithTags: ProductListQueryDto = {
|
||||
...query,
|
||||
tags: ['tag1', 'tag2'],
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithTags);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'p.tags ?| :tags',
|
||||
{ tags: ['tag1', 'tag2'] },
|
||||
);
|
||||
});
|
||||
|
||||
it('should apply custom sorting', async () => {
|
||||
const queryWithSort: ProductListQueryDto = {
|
||||
...query,
|
||||
sortBy: 'base_price',
|
||||
sortOrder: 'ASC',
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryWithSort);
|
||||
|
||||
expect(mockQueryBuilder.orderBy).toHaveBeenCalledWith('p.base_price', 'ASC');
|
||||
});
|
||||
|
||||
it('should limit max results to 100', async () => {
|
||||
const queryLargeLimit: ProductListQueryDto = {
|
||||
limit: 500,
|
||||
};
|
||||
|
||||
await service.findAll(tenantId, queryLargeLimit);
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a product by id with variant count', async () => {
|
||||
productRepo.findOne.mockResolvedValue(mockProduct);
|
||||
variantRepo.count.mockResolvedValue(3);
|
||||
|
||||
const result = await service.findOne(tenantId, 'prod-123');
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe('prod-123');
|
||||
expect(result.variantCount).toBe(3);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
await expect(service.findOne(tenantId, 'non-existent')).rejects.toThrow(
|
||||
'Product not found',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
const updateDto: UpdateProductDto = {
|
||||
name: 'Updated Product',
|
||||
basePrice: 149.99,
|
||||
};
|
||||
|
||||
it('should update a product successfully', async () => {
|
||||
const updatedProduct = { ...mockProduct, ...updateDto };
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct });
|
||||
productRepo.save.mockResolvedValue(updatedProduct);
|
||||
|
||||
const result = await service.update(tenantId, 'prod-123', updateDto);
|
||||
|
||||
expect(result.name).toBe('Updated Product');
|
||||
expect(result.basePrice).toBe(149.99);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(tenantId, 'non-existent', updateDto)).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should only update provided fields', async () => {
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct });
|
||||
productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity));
|
||||
|
||||
const result = await service.update(tenantId, 'prod-123', { name: 'Only Name' });
|
||||
|
||||
expect(result.name).toBe('Only Name');
|
||||
expect(result.slug).toBe(mockProduct.slug);
|
||||
expect(result.basePrice).toBe(mockProduct.basePrice);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update product status successfully', async () => {
|
||||
const statusDto: UpdateProductStatusDto = {
|
||||
status: ProductStatus.ACTIVE,
|
||||
};
|
||||
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct });
|
||||
productRepo.save.mockResolvedValue({
|
||||
...mockProduct,
|
||||
status: ProductStatus.ACTIVE,
|
||||
publishedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await service.updateStatus(tenantId, 'prod-123', statusDto);
|
||||
|
||||
expect(result.status).toBe(ProductStatus.ACTIVE);
|
||||
});
|
||||
|
||||
it('should set publishedAt when activating product', async () => {
|
||||
const statusDto: UpdateProductStatusDto = {
|
||||
status: ProductStatus.ACTIVE,
|
||||
};
|
||||
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct, publishedAt: null });
|
||||
productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity));
|
||||
|
||||
await service.updateStatus(tenantId, 'prod-123', statusDto);
|
||||
|
||||
expect(productRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ publishedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not update publishedAt if already set', async () => {
|
||||
const existingDate = new Date('2026-01-01');
|
||||
const statusDto: UpdateProductStatusDto = {
|
||||
status: ProductStatus.ACTIVE,
|
||||
};
|
||||
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct, publishedAt: existingDate });
|
||||
productRepo.save.mockImplementation((prod) => Promise.resolve(prod as ProductEntity));
|
||||
|
||||
await service.updateStatus(tenantId, 'prod-123', statusDto);
|
||||
|
||||
expect(productRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ publishedAt: existingDate }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateStatus(tenantId, 'non-existent', { status: ProductStatus.ACTIVE }),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicate', () => {
|
||||
it('should duplicate a product successfully', async () => {
|
||||
const duplicated = {
|
||||
...mockProduct,
|
||||
id: 'prod-456',
|
||||
name: 'Test Product (Copy)',
|
||||
slug: expect.stringContaining('test-product-copy-'),
|
||||
sku: 'SKU-001-COPY',
|
||||
status: ProductStatus.DRAFT,
|
||||
publishedAt: null,
|
||||
};
|
||||
|
||||
productRepo.findOne.mockResolvedValue(mockProduct);
|
||||
productRepo.create.mockReturnValue(duplicated);
|
||||
productRepo.save.mockResolvedValue(duplicated);
|
||||
|
||||
const result = await service.duplicate(tenantId, userId, 'prod-123');
|
||||
|
||||
expect(result.name).toContain('(Copy)');
|
||||
expect(result.status).toBe(ProductStatus.DRAFT);
|
||||
expect(result.publishedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.duplicate(tenantId, userId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft-delete a product successfully', async () => {
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct });
|
||||
productRepo.save.mockResolvedValue({ ...mockProduct, deletedAt: new Date() });
|
||||
|
||||
await expect(service.remove(tenantId, 'prod-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(productRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Variants Tests
|
||||
// ============================================
|
||||
|
||||
describe('getVariants', () => {
|
||||
it('should return variants for a product', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.find.mockResolvedValue([mockVariant]);
|
||||
|
||||
const result = await service.getVariants(tenantId, 'prod-123');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockVariant.id);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(service.getVariants(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no variants exist', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.find.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getVariants(tenantId, 'prod-123');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createVariant', () => {
|
||||
const createVariantDto: CreateVariantDto = {
|
||||
sku: 'SKU-001-RED',
|
||||
name: 'Red Variant',
|
||||
attributes: { color: 'red' },
|
||||
price: 109.99,
|
||||
};
|
||||
|
||||
it('should create a variant successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.create.mockReturnValue(mockVariant);
|
||||
variantRepo.save.mockResolvedValue(mockVariant);
|
||||
|
||||
const result = await service.createVariant(tenantId, 'prod-123', createVariantDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(mockVariant.id);
|
||||
expect(variantRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId,
|
||||
productId: 'prod-123',
|
||||
sku: createVariantDto.sku,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
service.createVariant(tenantId, 'non-existent', createVariantDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateVariant', () => {
|
||||
const updateVariantDto: UpdateVariantDto = {
|
||||
name: 'Updated Red Variant',
|
||||
price: 119.99,
|
||||
};
|
||||
|
||||
it('should update a variant successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.findOne.mockResolvedValue({ ...mockVariant });
|
||||
variantRepo.save.mockResolvedValue({ ...mockVariant, ...updateVariantDto });
|
||||
|
||||
const result = await service.updateVariant(
|
||||
tenantId,
|
||||
'prod-123',
|
||||
'var-123',
|
||||
updateVariantDto,
|
||||
);
|
||||
|
||||
expect(result.name).toBe('Updated Red Variant');
|
||||
expect(result.price).toBe(119.99);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
service.updateVariant(tenantId, 'non-existent', 'var-123', updateVariantDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when variant not found', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updateVariant(tenantId, 'prod-123', 'non-existent', updateVariantDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.updateVariant(tenantId, 'prod-123', 'non-existent', updateVariantDto),
|
||||
).rejects.toThrow('Variant not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeVariant', () => {
|
||||
it('should soft-delete a variant successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.findOne.mockResolvedValue({ ...mockVariant });
|
||||
variantRepo.save.mockResolvedValue({ ...mockVariant, deletedAt: new Date() });
|
||||
|
||||
await expect(service.removeVariant(tenantId, 'prod-123', 'var-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(variantRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when variant not found', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.removeVariant(tenantId, 'prod-123', 'non-existent'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Prices Tests
|
||||
// ============================================
|
||||
|
||||
describe('getPrices', () => {
|
||||
it('should return prices for a product', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.find.mockResolvedValue([mockPrice]);
|
||||
|
||||
const result = await service.getPrices(tenantId, 'prod-123');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(mockPrice.id);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(service.getPrices(tenantId, 'non-existent')).rejects.toThrow(
|
||||
NotFoundException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPrice', () => {
|
||||
const createPriceDto: CreatePriceDto = {
|
||||
currency: 'USD',
|
||||
amount: 99.99,
|
||||
priceType: PriceType.ONE_TIME,
|
||||
};
|
||||
|
||||
it('should create a price successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.create.mockReturnValue(mockPrice);
|
||||
priceRepo.save.mockResolvedValue(mockPrice);
|
||||
|
||||
const result = await service.createPrice(tenantId, 'prod-123', createPriceDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.id).toBe(mockPrice.id);
|
||||
expect(priceRepo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tenantId,
|
||||
productId: 'prod-123',
|
||||
currency: createPriceDto.currency,
|
||||
amount: createPriceDto.amount,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a recurring price', async () => {
|
||||
const recurringPriceDto: CreatePriceDto = {
|
||||
...createPriceDto,
|
||||
priceType: PriceType.RECURRING,
|
||||
billingPeriod: 'month',
|
||||
billingInterval: 1,
|
||||
};
|
||||
|
||||
const recurringPrice = {
|
||||
...mockPrice,
|
||||
priceType: PriceType.RECURRING,
|
||||
billingPeriod: 'month',
|
||||
billingInterval: 1,
|
||||
};
|
||||
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.create.mockReturnValue(recurringPrice);
|
||||
priceRepo.save.mockResolvedValue(recurringPrice);
|
||||
|
||||
const result = await service.createPrice(tenantId, 'prod-123', recurringPriceDto);
|
||||
|
||||
expect(result.priceType).toBe(PriceType.RECURRING);
|
||||
expect(result.billingPeriod).toBe('month');
|
||||
});
|
||||
|
||||
it('should create a price with validity period', async () => {
|
||||
const validityPriceDto: CreatePriceDto = {
|
||||
...createPriceDto,
|
||||
validFrom: '2026-01-01',
|
||||
validUntil: '2026-12-31',
|
||||
};
|
||||
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.create.mockReturnValue({
|
||||
...mockPrice,
|
||||
validFrom: new Date('2026-01-01'),
|
||||
validUntil: new Date('2026-12-31'),
|
||||
});
|
||||
priceRepo.save.mockResolvedValue({
|
||||
...mockPrice,
|
||||
validFrom: new Date('2026-01-01'),
|
||||
validUntil: new Date('2026-12-31'),
|
||||
});
|
||||
|
||||
const result = await service.createPrice(tenantId, 'prod-123', validityPriceDto);
|
||||
|
||||
expect(result.validFrom).toBeDefined();
|
||||
expect(result.validUntil).toBeDefined();
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when product not found', async () => {
|
||||
productRepo.count.mockResolvedValue(0);
|
||||
|
||||
await expect(
|
||||
service.createPrice(tenantId, 'non-existent', createPriceDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updatePrice', () => {
|
||||
const updatePriceDto: UpdatePriceDto = {
|
||||
amount: 149.99,
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
it('should update a price successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.findOne.mockResolvedValue({ ...mockPrice });
|
||||
priceRepo.save.mockResolvedValue({ ...mockPrice, ...updatePriceDto });
|
||||
|
||||
const result = await service.updatePrice(
|
||||
tenantId,
|
||||
'prod-123',
|
||||
'price-123',
|
||||
updatePriceDto,
|
||||
);
|
||||
|
||||
expect(result.amount).toBe(149.99);
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when price not found', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.updatePrice(tenantId, 'prod-123', 'non-existent', updatePriceDto),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
await expect(
|
||||
service.updatePrice(tenantId, 'prod-123', 'non-existent', updatePriceDto),
|
||||
).rejects.toThrow('Price not found');
|
||||
});
|
||||
|
||||
it('should allow clearing validity dates', async () => {
|
||||
const clearValidityDto: UpdatePriceDto = {
|
||||
validFrom: null,
|
||||
validUntil: null,
|
||||
};
|
||||
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.findOne.mockResolvedValue({
|
||||
...mockPrice,
|
||||
validFrom: new Date(),
|
||||
validUntil: new Date(),
|
||||
});
|
||||
priceRepo.save.mockImplementation((p) => Promise.resolve(p as PriceEntity));
|
||||
|
||||
await service.updatePrice(tenantId, 'prod-123', 'price-123', clearValidityDto);
|
||||
|
||||
expect(priceRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ validFrom: null, validUntil: null }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removePrice', () => {
|
||||
it('should soft-delete a price successfully', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.findOne.mockResolvedValue({ ...mockPrice });
|
||||
priceRepo.save.mockResolvedValue({ ...mockPrice, deletedAt: new Date() });
|
||||
|
||||
await expect(service.removePrice(tenantId, 'prod-123', 'price-123')).resolves.toBeUndefined();
|
||||
|
||||
expect(priceRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ deletedAt: expect.any(Date) }),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when price not found', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.removePrice(tenantId, 'prod-123', 'non-existent'),
|
||||
).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Response Mapping Tests
|
||||
// ============================================
|
||||
|
||||
describe('toProductResponse mapping', () => {
|
||||
it('should correctly map entity to response DTO', async () => {
|
||||
const productWithCategory = {
|
||||
...mockProduct,
|
||||
category: {
|
||||
id: 'cat-123',
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
},
|
||||
};
|
||||
|
||||
productRepo.findOne.mockResolvedValue(productWithCategory);
|
||||
variantRepo.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.findOne(tenantId, 'prod-123');
|
||||
|
||||
expect(result.category).toEqual({
|
||||
id: 'cat-123',
|
||||
name: 'Electronics',
|
||||
slug: 'electronics',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null category', async () => {
|
||||
productRepo.findOne.mockResolvedValue({ ...mockProduct, category: null });
|
||||
variantRepo.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.findOne(tenantId, 'prod-123');
|
||||
|
||||
expect(result.category).toBeNull();
|
||||
});
|
||||
|
||||
it('should convert decimal fields to numbers', async () => {
|
||||
productRepo.findOne.mockResolvedValue(mockProduct);
|
||||
variantRepo.count.mockResolvedValue(0);
|
||||
|
||||
const result = await service.findOne(tenantId, 'prod-123');
|
||||
|
||||
expect(typeof result.basePrice).toBe('number');
|
||||
expect(typeof result.costPrice).toBe('number');
|
||||
expect(typeof result.compareAtPrice).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('toVariantResponse mapping', () => {
|
||||
it('should correctly map variant entity to response DTO', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
variantRepo.find.mockResolvedValue([mockVariant]);
|
||||
|
||||
const result = await service.getVariants(tenantId, 'prod-123');
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
id: mockVariant.id,
|
||||
tenantId: mockVariant.tenantId,
|
||||
productId: mockVariant.productId,
|
||||
sku: mockVariant.sku,
|
||||
barcode: mockVariant.barcode,
|
||||
name: mockVariant.name,
|
||||
attributes: mockVariant.attributes,
|
||||
price: Number(mockVariant.price),
|
||||
costPrice: Number(mockVariant.costPrice),
|
||||
compareAtPrice: Number(mockVariant.compareAtPrice),
|
||||
stockQuantity: mockVariant.stockQuantity,
|
||||
lowStockThreshold: mockVariant.lowStockThreshold,
|
||||
weight: Number(mockVariant.weight),
|
||||
imageUrl: mockVariant.imageUrl,
|
||||
isActive: mockVariant.isActive,
|
||||
position: mockVariant.position,
|
||||
createdAt: mockVariant.createdAt,
|
||||
updatedAt: mockVariant.updatedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('toPriceResponse mapping', () => {
|
||||
it('should correctly map price entity to response DTO', async () => {
|
||||
productRepo.count.mockResolvedValue(1);
|
||||
priceRepo.find.mockResolvedValue([mockPrice]);
|
||||
|
||||
const result = await service.getPrices(tenantId, 'prod-123');
|
||||
|
||||
expect(result[0]).toEqual({
|
||||
id: mockPrice.id,
|
||||
tenantId: mockPrice.tenantId,
|
||||
productId: mockPrice.productId,
|
||||
variantId: mockPrice.variantId,
|
||||
priceType: mockPrice.priceType,
|
||||
currency: mockPrice.currency,
|
||||
amount: Number(mockPrice.amount),
|
||||
compareAtAmount: Number(mockPrice.compareAtAmount),
|
||||
billingPeriod: mockPrice.billingPeriod,
|
||||
billingInterval: mockPrice.billingInterval,
|
||||
minQuantity: mockPrice.minQuantity,
|
||||
maxQuantity: mockPrice.maxQuantity,
|
||||
validFrom: mockPrice.validFrom,
|
||||
validUntil: mockPrice.validUntil,
|
||||
priority: mockPrice.priority,
|
||||
isActive: mockPrice.isActive,
|
||||
createdAt: mockPrice.createdAt,
|
||||
updatedAt: mockPrice.updatedAt,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
573
src/modules/sales/__tests__/activities.service.spec.ts
Normal file
573
src/modules/sales/__tests__/activities.service.spec.ts
Normal file
@ -0,0 +1,573 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ActivitiesService } from '../services/activities.service';
|
||||
import { ActivityEntity, ActivityType, ActivityStatus } from '../entities';
|
||||
|
||||
describe('ActivitiesService', () => {
|
||||
let service: ActivitiesService;
|
||||
let activityRepo: jest.Mocked<Repository<ActivityEntity>>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
const mockActivityId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const mockLeadId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
|
||||
const mockActivity: Partial<ActivityEntity> = {
|
||||
id: mockActivityId,
|
||||
tenantId: mockTenantId,
|
||||
type: ActivityType.CALL,
|
||||
status: ActivityStatus.PENDING,
|
||||
subject: 'Follow-up call',
|
||||
description: 'Call to discuss next steps',
|
||||
leadId: mockLeadId,
|
||||
opportunityId: null,
|
||||
dueDate: new Date('2026-02-01'),
|
||||
dueTime: '10:00:00',
|
||||
durationMinutes: 30,
|
||||
assignedTo: mockUserId,
|
||||
createdBy: mockUserId,
|
||||
callDirection: 'outbound',
|
||||
location: null,
|
||||
meetingUrl: null,
|
||||
attendees: [],
|
||||
reminderAt: new Date('2026-02-01T09:30:00Z'),
|
||||
reminderSent: false,
|
||||
customFields: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockActivityRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ActivitiesService,
|
||||
{ provide: getRepositoryToken(ActivityEntity), useValue: mockActivityRepo },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ActivitiesService>(ActivitiesService);
|
||||
activityRepo = module.get(getRepositoryToken(ActivityEntity));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new activity with leadId', async () => {
|
||||
const createDto = {
|
||||
type: ActivityType.CALL,
|
||||
subject: 'Follow-up call',
|
||||
description: 'Call to discuss next steps',
|
||||
leadId: mockLeadId,
|
||||
dueDate: '2026-02-01',
|
||||
dueTime: '10:00:00',
|
||||
durationMinutes: 30,
|
||||
assignedTo: mockUserId,
|
||||
};
|
||||
|
||||
activityRepo.create.mockReturnValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tenantId: mockTenantId,
|
||||
type: ActivityType.CALL,
|
||||
subject: 'Follow-up call',
|
||||
leadId: mockLeadId,
|
||||
createdBy: mockUserId,
|
||||
}));
|
||||
expect(result.id).toBe(mockActivityId);
|
||||
});
|
||||
|
||||
it('should create a new activity with opportunityId', async () => {
|
||||
const createDto = {
|
||||
type: ActivityType.MEETING,
|
||||
subject: 'Sales meeting',
|
||||
opportunityId: mockOpportunityId,
|
||||
dueDate: '2026-02-05',
|
||||
location: 'Conference Room A',
|
||||
attendees: ['john@example.com', 'jane@example.com'],
|
||||
};
|
||||
|
||||
const activityWithOpportunity = { ...mockActivity, opportunityId: mockOpportunityId, leadId: null };
|
||||
activityRepo.create.mockReturnValue(activityWithOpportunity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(activityWithOpportunity as ActivityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
opportunityId: mockOpportunityId,
|
||||
type: ActivityType.MEETING,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when neither leadId nor opportunityId provided', async () => {
|
||||
const createDto = {
|
||||
type: ActivityType.TASK,
|
||||
subject: 'Some task',
|
||||
};
|
||||
|
||||
await expect(service.create(mockTenantId, mockUserId, createDto))
|
||||
.rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should create activity with reminder', async () => {
|
||||
const createDto = {
|
||||
type: ActivityType.EMAIL,
|
||||
subject: 'Send proposal',
|
||||
leadId: mockLeadId,
|
||||
reminderAt: '2026-02-01T09:00:00Z',
|
||||
};
|
||||
|
||||
activityRepo.create.mockReturnValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
|
||||
await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
reminderAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should create meeting activity with meeting URL', async () => {
|
||||
const createDto = {
|
||||
type: ActivityType.MEETING,
|
||||
subject: 'Video call',
|
||||
leadId: mockLeadId,
|
||||
meetingUrl: 'https://zoom.us/j/123456789',
|
||||
};
|
||||
|
||||
const meetingActivity = { ...mockActivity, meetingUrl: 'https://zoom.us/j/123456789' };
|
||||
activityRepo.create.mockReturnValue(meetingActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(meetingActivity as ActivityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(activityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
meetingUrl: 'https://zoom.us/j/123456789',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated activities', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 20 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { type: ActivityType.CALL });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.type = :type',
|
||||
{ type: ActivityType.CALL }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { status: ActivityStatus.PENDING });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.status = :status',
|
||||
{ status: ActivityStatus.PENDING }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by leadId', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { leadId: mockLeadId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.lead_id = :leadId',
|
||||
{ leadId: mockLeadId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by opportunityId', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { opportunityId: mockOpportunityId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.opportunity_id = :opportunityId',
|
||||
{ opportunityId: mockOpportunityId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by assignedTo', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[mockActivity], 1]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { assignedTo: mockUserId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.assigned_to = :assignedTo',
|
||||
{ assignedTo: mockUserId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit results to max 100', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[], 0]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { limit: 500 });
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return an activity by id', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockActivityId);
|
||||
|
||||
expect(result.id).toBe(mockActivityId);
|
||||
expect(result.subject).toBe('Follow-up call');
|
||||
expect(activityRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockActivityId, tenantId: mockTenantId, deletedAt: null },
|
||||
relations: ['lead', 'opportunity'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when activity not found', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an activity successfully', async () => {
|
||||
const updateDto = {
|
||||
subject: 'Updated follow-up call',
|
||||
dueDate: '2026-02-05',
|
||||
durationMinutes: 45,
|
||||
};
|
||||
|
||||
const updatedActivity = { ...mockActivity, ...updateDto };
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockActivityId, updateDto);
|
||||
|
||||
expect(result.subject).toBe('Updated follow-up call');
|
||||
expect(result.durationMinutes).toBe(45);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when activity not found', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, 'non-existent-id', { subject: 'Test' }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update activity type', async () => {
|
||||
const updateDto = {
|
||||
type: ActivityType.MEETING,
|
||||
location: 'Conference Room B',
|
||||
};
|
||||
|
||||
const updatedActivity = { ...mockActivity, type: ActivityType.MEETING, location: 'Conference Room B' };
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockActivityId, updateDto);
|
||||
|
||||
expect(result.type).toBe(ActivityType.MEETING);
|
||||
expect(result.location).toBe('Conference Room B');
|
||||
});
|
||||
|
||||
it('should update activity status', async () => {
|
||||
const updateDto = {
|
||||
status: ActivityStatus.CANCELLED,
|
||||
};
|
||||
|
||||
const updatedActivity = { ...mockActivity, status: ActivityStatus.CANCELLED };
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockActivityId, updateDto);
|
||||
|
||||
expect(result.status).toBe(ActivityStatus.CANCELLED);
|
||||
});
|
||||
|
||||
it('should update attendees list', async () => {
|
||||
const updateDto = {
|
||||
attendees: ['john@example.com', 'jane@example.com', 'bob@example.com'],
|
||||
};
|
||||
|
||||
const updatedActivity = { ...mockActivity, attendees: updateDto.attendees };
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(updatedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockActivityId, updateDto);
|
||||
|
||||
expect(result.attendees).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('complete', () => {
|
||||
it('should complete an activity', async () => {
|
||||
const completeDto = {
|
||||
outcome: 'Successfully discussed next steps',
|
||||
};
|
||||
|
||||
const completedActivity = {
|
||||
...mockActivity,
|
||||
status: ActivityStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
outcome: completeDto.outcome,
|
||||
};
|
||||
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(completedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.complete(mockTenantId, mockActivityId, completeDto);
|
||||
|
||||
expect(result.status).toBe(ActivityStatus.COMPLETED);
|
||||
expect(result.outcome).toBe('Successfully discussed next steps');
|
||||
expect(activityRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: ActivityStatus.COMPLETED,
|
||||
completedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when activity not found', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.complete(mockTenantId, 'non-existent-id', {}))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when activity is already completed', async () => {
|
||||
const completedActivity = { ...mockActivity, status: ActivityStatus.COMPLETED };
|
||||
activityRepo.findOne.mockResolvedValue(completedActivity as ActivityEntity);
|
||||
|
||||
await expect(service.complete(mockTenantId, mockActivityId, {}))
|
||||
.rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it('should complete activity without outcome', async () => {
|
||||
const pendingActivity = {
|
||||
...mockActivity,
|
||||
status: ActivityStatus.PENDING,
|
||||
};
|
||||
const completedActivity = {
|
||||
...mockActivity,
|
||||
status: ActivityStatus.COMPLETED,
|
||||
completedAt: new Date(),
|
||||
};
|
||||
|
||||
activityRepo.findOne.mockResolvedValue(pendingActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue(completedActivity as ActivityEntity);
|
||||
|
||||
const result = await service.complete(mockTenantId, mockActivityId, {});
|
||||
|
||||
expect(result.status).toBe(ActivityStatus.COMPLETED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft delete an activity', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(mockActivity as ActivityEntity);
|
||||
activityRepo.save.mockResolvedValue({ ...mockActivity, deletedAt: new Date() } as ActivityEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockActivityId);
|
||||
|
||||
expect(activityRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
deletedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when activity not found', async () => {
|
||||
activityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUpcoming', () => {
|
||||
it('should return upcoming activities within default 7 days', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockActivity]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getUpcoming(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.status = :status',
|
||||
{ status: ActivityStatus.PENDING }
|
||||
);
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('should filter by userId when provided', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockActivity]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.getUpcoming(mockTenantId, mockUserId);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.assigned_to = :userId',
|
||||
{ userId: mockUserId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should return upcoming activities within custom days', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([mockActivity]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.getUpcoming(mockTenantId, undefined, 14);
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'a.due_date <= :endDate',
|
||||
expect.objectContaining({
|
||||
endDate: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no upcoming activities', async () => {
|
||||
const mockQueryBuilder = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
|
||||
activityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.getUpcoming(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
402
src/modules/sales/__tests__/leads.service.spec.ts
Normal file
402
src/modules/sales/__tests__/leads.service.spec.ts
Normal file
@ -0,0 +1,402 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { LeadsService } from '../services/leads.service';
|
||||
import { LeadEntity, LeadStatus, LeadSource } from '../entities';
|
||||
|
||||
describe('LeadsService', () => {
|
||||
let service: LeadsService;
|
||||
let leadRepo: jest.Mocked<Repository<LeadEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
const mockLeadId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
|
||||
const mockLead: Partial<LeadEntity> = {
|
||||
id: mockLeadId,
|
||||
tenantId: mockTenantId,
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1234567890',
|
||||
company: 'Acme Inc',
|
||||
jobTitle: 'CEO',
|
||||
website: 'https://acme.com',
|
||||
source: LeadSource.WEBSITE,
|
||||
status: LeadStatus.NEW,
|
||||
score: 50,
|
||||
assignedTo: mockUserId,
|
||||
notes: 'Important lead',
|
||||
customFields: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockLeadRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
LeadsService,
|
||||
{ provide: getRepositoryToken(LeadEntity), useValue: mockLeadRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<LeadsService>(LeadsService);
|
||||
leadRepo = module.get(getRepositoryToken(LeadEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new lead successfully', async () => {
|
||||
const createDto = {
|
||||
firstName: 'John',
|
||||
lastName: 'Doe',
|
||||
email: 'john.doe@example.com',
|
||||
phone: '+1234567890',
|
||||
company: 'Acme Inc',
|
||||
source: LeadSource.WEBSITE,
|
||||
status: LeadStatus.NEW,
|
||||
};
|
||||
|
||||
leadRepo.create.mockReturnValue(mockLead as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue(mockLead as LeadEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(leadRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tenantId: mockTenantId,
|
||||
firstName: createDto.firstName,
|
||||
lastName: createDto.lastName,
|
||||
email: createDto.email,
|
||||
createdBy: mockUserId,
|
||||
}));
|
||||
expect(leadRepo.save).toHaveBeenCalled();
|
||||
expect(result.id).toBe(mockLeadId);
|
||||
expect(result.fullName).toBe('John Doe');
|
||||
});
|
||||
|
||||
it('should create lead with default score of 0', async () => {
|
||||
const createDto = {
|
||||
firstName: 'Jane',
|
||||
lastName: 'Smith',
|
||||
};
|
||||
|
||||
leadRepo.create.mockReturnValue({ ...mockLead, score: 0 } as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue({ ...mockLead, score: 0 } as LeadEntity);
|
||||
|
||||
await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(leadRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
score: 0,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated leads', 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([[mockLead], 1]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 20 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.totalPages).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([[mockLead], 1]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { status: LeadStatus.NEW });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'l.status = :status',
|
||||
{ status: LeadStatus.NEW }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by source', 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([[mockLead], 1]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { source: LeadSource.WEBSITE });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'l.source = :source',
|
||||
{ source: LeadSource.WEBSITE }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by assignedTo', 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([[mockLead], 1]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { assignedTo: mockUserId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'l.assigned_to = :assignedTo',
|
||||
{ assignedTo: mockUserId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should search by name, email, or company', 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([[mockLead], 1]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { search: 'John' });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(l.first_name ILIKE :search OR l.last_name ILIKE :search OR l.email ILIKE :search OR l.company ILIKE :search)',
|
||||
{ search: '%John%' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit results to max 100', 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]),
|
||||
};
|
||||
|
||||
leadRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { limit: 500 });
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a lead by id', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockLeadId);
|
||||
|
||||
expect(result.id).toBe(mockLeadId);
|
||||
expect(result.email).toBe('john.doe@example.com');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a lead successfully', async () => {
|
||||
const updateDto = {
|
||||
firstName: 'Jane',
|
||||
email: 'jane.doe@example.com',
|
||||
};
|
||||
|
||||
const updatedLead = { ...mockLead, ...updateDto };
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue(updatedLead as LeadEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockLeadId, updateDto);
|
||||
|
||||
expect(result.firstName).toBe('Jane');
|
||||
expect(result.email).toBe('jane.doe@example.com');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, 'non-existent-id', { firstName: 'Test' }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update status to qualified', async () => {
|
||||
const updateDto = { status: LeadStatus.QUALIFIED };
|
||||
const updatedLead = { ...mockLead, status: LeadStatus.QUALIFIED };
|
||||
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue(updatedLead as LeadEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockLeadId, updateDto);
|
||||
|
||||
expect(result.status).toBe(LeadStatus.QUALIFIED);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft delete a lead', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue({ ...mockLead, deletedAt: new Date() } as LeadEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockLeadId);
|
||||
|
||||
expect(leadRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
deletedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convert', () => {
|
||||
it('should convert a lead to opportunity', async () => {
|
||||
const convertDto = {
|
||||
opportunityName: 'New Deal',
|
||||
amount: 10000,
|
||||
expectedCloseDate: '2026-03-01',
|
||||
};
|
||||
|
||||
const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440010';
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
dataSource.query.mockResolvedValue([{ opportunity_id: mockOpportunityId }]);
|
||||
|
||||
const result = await service.convert(mockTenantId, mockLeadId, convertDto);
|
||||
|
||||
expect(result.opportunityId).toBe(mockOpportunityId);
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('convert_lead_to_opportunity'),
|
||||
[mockLeadId, convertDto.opportunityName, convertDto.amount, convertDto.expectedCloseDate]
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.convert(mockTenantId, 'non-existent-id', {}))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw BadRequestException when lead is already converted', async () => {
|
||||
const convertedLead = { ...mockLead, status: LeadStatus.CONVERTED };
|
||||
leadRepo.findOne.mockResolvedValue(convertedLead as LeadEntity);
|
||||
|
||||
await expect(service.convert(mockTenantId, mockLeadId, {}))
|
||||
.rejects.toThrow(BadRequestException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('calculateScore', () => {
|
||||
it('should calculate and update lead score', async () => {
|
||||
const expectedScore = 75;
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
dataSource.query.mockResolvedValue([{ score: expectedScore }]);
|
||||
leadRepo.save.mockResolvedValue({ ...mockLead, score: expectedScore } as LeadEntity);
|
||||
|
||||
const result = await service.calculateScore(mockTenantId, mockLeadId);
|
||||
|
||||
expect(result.score).toBe(expectedScore);
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('calculate_lead_score'),
|
||||
[mockLeadId]
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.calculateScore(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should default to 0 when score calculation returns null', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
dataSource.query.mockResolvedValue([{ score: null }]);
|
||||
leadRepo.save.mockResolvedValue({ ...mockLead, score: 0 } as LeadEntity);
|
||||
|
||||
const result = await service.calculateScore(mockTenantId, mockLeadId);
|
||||
|
||||
expect(result.score).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignTo', () => {
|
||||
it('should assign a lead to a user', async () => {
|
||||
const newAssignee = '550e8400-e29b-41d4-a716-446655440099';
|
||||
const assignedLead = { ...mockLead, assignedTo: newAssignee };
|
||||
|
||||
leadRepo.findOne.mockResolvedValue(mockLead as LeadEntity);
|
||||
leadRepo.save.mockResolvedValue(assignedLead as LeadEntity);
|
||||
|
||||
const result = await service.assignTo(mockTenantId, mockLeadId, newAssignee);
|
||||
|
||||
expect(result.assignedTo).toBe(newAssignee);
|
||||
expect(leadRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
assignedTo: newAssignee,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when lead not found', async () => {
|
||||
leadRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.assignTo(mockTenantId, 'non-existent-id', mockUserId))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
490
src/modules/sales/__tests__/opportunities.service.spec.ts
Normal file
490
src/modules/sales/__tests__/opportunities.service.spec.ts
Normal file
@ -0,0 +1,490 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource } from 'typeorm';
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { OpportunitiesService } from '../services/opportunities.service';
|
||||
import { OpportunityEntity, OpportunityStage } from '../entities';
|
||||
|
||||
describe('OpportunitiesService', () => {
|
||||
let service: OpportunitiesService;
|
||||
let opportunityRepo: jest.Mocked<Repository<OpportunityEntity>>;
|
||||
let dataSource: jest.Mocked<DataSource>;
|
||||
|
||||
const mockTenantId = '550e8400-e29b-41d4-a716-446655440001';
|
||||
const mockUserId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
const mockOpportunityId = '550e8400-e29b-41d4-a716-446655440003';
|
||||
const mockLeadId = '550e8400-e29b-41d4-a716-446655440004';
|
||||
|
||||
const mockOpportunity: Partial<OpportunityEntity> = {
|
||||
id: mockOpportunityId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Big Deal',
|
||||
description: 'A very big deal',
|
||||
leadId: mockLeadId,
|
||||
stage: OpportunityStage.QUALIFICATION,
|
||||
stageId: '550e8400-e29b-41d4-a716-446655440005',
|
||||
amount: 50000,
|
||||
currency: 'USD',
|
||||
probability: 60,
|
||||
expectedCloseDate: new Date('2026-06-01'),
|
||||
assignedTo: mockUserId,
|
||||
contactName: 'Jane Doe',
|
||||
contactEmail: 'jane@example.com',
|
||||
contactPhone: '+1234567890',
|
||||
companyName: 'Acme Inc',
|
||||
notes: 'Important opportunity',
|
||||
customFields: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
createdBy: mockUserId,
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockOpportunityRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
};
|
||||
|
||||
const mockDataSource = {
|
||||
query: jest.fn(),
|
||||
};
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
OpportunitiesService,
|
||||
{ provide: getRepositoryToken(OpportunityEntity), useValue: mockOpportunityRepo },
|
||||
{ provide: DataSource, useValue: mockDataSource },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<OpportunitiesService>(OpportunitiesService);
|
||||
opportunityRepo = module.get(getRepositoryToken(OpportunityEntity));
|
||||
dataSource = module.get(DataSource);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new opportunity successfully', async () => {
|
||||
const createDto = {
|
||||
name: 'Big Deal',
|
||||
description: 'A very big deal',
|
||||
leadId: mockLeadId,
|
||||
amount: 50000,
|
||||
probability: 60,
|
||||
expectedCloseDate: '2026-06-01',
|
||||
contactName: 'Jane Doe',
|
||||
companyName: 'Acme Inc',
|
||||
};
|
||||
|
||||
opportunityRepo.create.mockReturnValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tenantId: mockTenantId,
|
||||
name: createDto.name,
|
||||
leadId: createDto.leadId,
|
||||
createdBy: mockUserId,
|
||||
}));
|
||||
expect(result.id).toBe(mockOpportunityId);
|
||||
expect(result.name).toBe('Big Deal');
|
||||
});
|
||||
|
||||
it('should create opportunity with default stage PROSPECTING', async () => {
|
||||
const createDto = {
|
||||
name: 'New Opportunity',
|
||||
};
|
||||
|
||||
opportunityRepo.create.mockReturnValue({
|
||||
...mockOpportunity,
|
||||
stage: OpportunityStage.PROSPECTING
|
||||
} as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue({
|
||||
...mockOpportunity,
|
||||
stage: OpportunityStage.PROSPECTING
|
||||
} as OpportunityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
stage: OpportunityStage.PROSPECTING,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should create opportunity with custom stage', async () => {
|
||||
const createDto = {
|
||||
name: 'Hot Lead',
|
||||
stage: OpportunityStage.PROPOSAL,
|
||||
};
|
||||
|
||||
opportunityRepo.create.mockReturnValue({
|
||||
...mockOpportunity,
|
||||
stage: OpportunityStage.PROPOSAL
|
||||
} as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue({
|
||||
...mockOpportunity,
|
||||
stage: OpportunityStage.PROPOSAL
|
||||
} as OpportunityEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(result.stage).toBe(OpportunityStage.PROPOSAL);
|
||||
});
|
||||
|
||||
it('should create opportunity with default currency USD', async () => {
|
||||
const createDto = {
|
||||
name: 'International Deal',
|
||||
amount: 10000,
|
||||
};
|
||||
|
||||
opportunityRepo.create.mockReturnValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
|
||||
await service.create(mockTenantId, mockUserId, createDto);
|
||||
|
||||
expect(opportunityRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
currency: 'USD',
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return paginated opportunities', 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([[mockOpportunity], 1]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId, { page: 1, limit: 20 });
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.page).toBe(1);
|
||||
expect(result.totalPages).toBe(1);
|
||||
});
|
||||
|
||||
it('should filter by stage', 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([[mockOpportunity], 1]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { stage: OpportunityStage.QUALIFICATION });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'o.stage = :stage',
|
||||
{ stage: OpportunityStage.QUALIFICATION }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by stageId', 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([[mockOpportunity], 1]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
const stageId = '550e8400-e29b-41d4-a716-446655440005';
|
||||
|
||||
await service.findAll(mockTenantId, { stageId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'o.stage_id = :stageId',
|
||||
{ stageId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter by assignedTo', 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([[mockOpportunity], 1]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { assignedTo: mockUserId });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'o.assigned_to = :assignedTo',
|
||||
{ assignedTo: mockUserId }
|
||||
);
|
||||
});
|
||||
|
||||
it('should search by name, company, or contact', 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([[mockOpportunity], 1]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { search: 'Acme' });
|
||||
|
||||
expect(mockQueryBuilder.andWhere).toHaveBeenCalledWith(
|
||||
'(o.name ILIKE :search OR o.company_name ILIKE :search OR o.contact_name ILIKE :search)',
|
||||
{ search: '%Acme%' }
|
||||
);
|
||||
});
|
||||
|
||||
it('should limit results to max 100', 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([[], 0]),
|
||||
};
|
||||
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.findAll(mockTenantId, { limit: 500 });
|
||||
|
||||
expect(mockQueryBuilder.take).toHaveBeenCalledWith(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return an opportunity by id', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockOpportunityId);
|
||||
|
||||
expect(result.id).toBe(mockOpportunityId);
|
||||
expect(result.name).toBe('Big Deal');
|
||||
expect(opportunityRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockOpportunityId, tenantId: mockTenantId, deletedAt: null },
|
||||
relations: ['lead', 'pipelineStage'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when opportunity not found', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update an opportunity successfully', async () => {
|
||||
const updateDto = {
|
||||
name: 'Bigger Deal',
|
||||
amount: 75000,
|
||||
probability: 80,
|
||||
};
|
||||
|
||||
const updatedOpportunity = { ...mockOpportunity, ...updateDto };
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockOpportunityId, updateDto);
|
||||
|
||||
expect(result.name).toBe('Bigger Deal');
|
||||
expect(result.amount).toBe(75000);
|
||||
expect(result.probability).toBe(80);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when opportunity not found', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, 'non-existent-id', { name: 'Test' }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update stage and probability', async () => {
|
||||
const updateDto = {
|
||||
stage: OpportunityStage.NEGOTIATION,
|
||||
probability: 90,
|
||||
};
|
||||
|
||||
const updatedOpportunity = { ...mockOpportunity, ...updateDto };
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockOpportunityId, updateDto);
|
||||
|
||||
expect(result.stage).toBe(OpportunityStage.NEGOTIATION);
|
||||
expect(result.probability).toBe(90);
|
||||
});
|
||||
|
||||
it('should update lost reason when losing deal', async () => {
|
||||
const updateDto = {
|
||||
stage: OpportunityStage.CLOSED_LOST,
|
||||
lostReason: 'Price too high',
|
||||
};
|
||||
|
||||
const updatedOpportunity = { ...mockOpportunity, ...updateDto };
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(updatedOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockOpportunityId, updateDto);
|
||||
|
||||
expect(result.stage).toBe(OpportunityStage.CLOSED_LOST);
|
||||
expect(result.lostReason).toBe('Price too high');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStage', () => {
|
||||
it('should update opportunity stage using database function', async () => {
|
||||
const updateStageDto = {
|
||||
stage: OpportunityStage.PROPOSAL,
|
||||
notes: 'Moving to proposal stage',
|
||||
};
|
||||
|
||||
const updatedOpportunity = { ...mockOpportunity, stage: OpportunityStage.PROPOSAL };
|
||||
opportunityRepo.findOne
|
||||
.mockResolvedValueOnce(mockOpportunity as OpportunityEntity)
|
||||
.mockResolvedValueOnce(updatedOpportunity as OpportunityEntity);
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.updateStage(mockTenantId, mockOpportunityId, updateStageDto);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('update_opportunity_stage'),
|
||||
[mockOpportunityId, OpportunityStage.PROPOSAL, 'Moving to proposal stage']
|
||||
);
|
||||
expect(result.stage).toBe(OpportunityStage.PROPOSAL);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when opportunity not found', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.updateStage(mockTenantId, 'non-existent-id', { stage: OpportunityStage.PROPOSAL }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update stage without notes', async () => {
|
||||
const updateStageDto = {
|
||||
stage: OpportunityStage.CLOSED_WON,
|
||||
};
|
||||
|
||||
const updatedOpportunity = { ...mockOpportunity, stage: OpportunityStage.CLOSED_WON };
|
||||
opportunityRepo.findOne
|
||||
.mockResolvedValueOnce(mockOpportunity as OpportunityEntity)
|
||||
.mockResolvedValueOnce(updatedOpportunity as OpportunityEntity);
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
await service.updateStage(mockTenantId, mockOpportunityId, updateStageDto);
|
||||
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('update_opportunity_stage'),
|
||||
[mockOpportunityId, OpportunityStage.CLOSED_WON, null]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should soft delete an opportunity', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue({ ...mockOpportunity, deletedAt: new Date() } as OpportunityEntity);
|
||||
|
||||
await service.remove(mockTenantId, mockOpportunityId);
|
||||
|
||||
expect(opportunityRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
deletedAt: expect.any(Date),
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when opportunity not found', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPipelineSummary', () => {
|
||||
it('should return pipeline summary by stage', async () => {
|
||||
const mockPipelineData = [
|
||||
{ stage: OpportunityStage.PROSPECTING, count: 10, total_amount: 100000, avg_probability: 20 },
|
||||
{ stage: OpportunityStage.QUALIFICATION, count: 8, total_amount: 150000, avg_probability: 40 },
|
||||
{ stage: OpportunityStage.PROPOSAL, count: 5, total_amount: 200000, avg_probability: 60 },
|
||||
{ stage: OpportunityStage.CLOSED_WON, count: 3, total_amount: 75000, avg_probability: 100 },
|
||||
];
|
||||
|
||||
dataSource.query.mockResolvedValue(mockPipelineData);
|
||||
|
||||
const result = await service.getPipelineSummary(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result[0].stage).toBe(OpportunityStage.PROSPECTING);
|
||||
expect(result[0].count).toBe(10);
|
||||
expect(result[0].totalAmount).toBe(100000);
|
||||
expect(result[0].avgProbability).toBe(20);
|
||||
expect(dataSource.query).toHaveBeenCalledWith(
|
||||
expect.stringContaining('get_pipeline_summary'),
|
||||
[mockTenantId]
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when no opportunities', async () => {
|
||||
dataSource.query.mockResolvedValue([]);
|
||||
|
||||
const result = await service.getPipelineSummary(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assignTo', () => {
|
||||
it('should assign an opportunity to a user', async () => {
|
||||
const newAssignee = '550e8400-e29b-41d4-a716-446655440099';
|
||||
const assignedOpportunity = { ...mockOpportunity, assignedTo: newAssignee };
|
||||
|
||||
opportunityRepo.findOne.mockResolvedValue(mockOpportunity as OpportunityEntity);
|
||||
opportunityRepo.save.mockResolvedValue(assignedOpportunity as OpportunityEntity);
|
||||
|
||||
const result = await service.assignTo(mockTenantId, mockOpportunityId, newAssignee);
|
||||
|
||||
expect(result.assignedTo).toBe(newAssignee);
|
||||
expect(opportunityRepo.save).toHaveBeenCalledWith(expect.objectContaining({
|
||||
assignedTo: newAssignee,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when opportunity not found', async () => {
|
||||
opportunityRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.assignTo(mockTenantId, 'non-existent-id', mockUserId))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
});
|
||||
483
src/modules/sales/__tests__/pipeline.service.spec.ts
Normal file
483
src/modules/sales/__tests__/pipeline.service.spec.ts
Normal file
@ -0,0 +1,483 @@
|
||||
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 mockStageId = '550e8400-e29b-41d4-a716-446655440002';
|
||||
|
||||
const mockStage: Partial<PipelineStageEntity> = {
|
||||
id: mockStageId,
|
||||
tenantId: mockTenantId,
|
||||
name: 'Qualification',
|
||||
position: 2,
|
||||
color: '#3B82F6',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
const mockStages: Partial<PipelineStageEntity>[] = [
|
||||
{ id: '1', tenantId: mockTenantId, name: 'Prospecting', position: 1, color: '#9CA3AF', isWon: false, isLost: false, isActive: true },
|
||||
{ id: '2', tenantId: mockTenantId, name: 'Qualification', position: 2, color: '#3B82F6', isWon: false, isLost: false, isActive: true },
|
||||
{ id: '3', tenantId: mockTenantId, name: 'Proposal', position: 3, color: '#F59E0B', isWon: false, isLost: false, isActive: true },
|
||||
{ id: '4', tenantId: mockTenantId, name: 'Negotiation', position: 4, color: '#8B5CF6', isWon: false, isLost: false, isActive: true },
|
||||
{ id: '5', tenantId: mockTenantId, name: 'Closed Won', position: 5, color: '#10B981', isWon: true, isLost: false, isActive: true },
|
||||
{ id: '6', tenantId: mockTenantId, name: 'Closed Lost', position: 6, color: '#EF4444', isWon: false, isLost: true, isActive: true },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
const mockStageRepo = {
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
find: jest.fn(),
|
||||
findOne: 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(
|
||||
expect.stringContaining('initialize_default_stages'),
|
||||
[mockTenantId]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findAll', () => {
|
||||
it('should return all pipeline stages with opportunity stats', async () => {
|
||||
const mockOpportunityStats = [
|
||||
{ stageId: '1', count: 10, total: 100000 },
|
||||
{ stageId: '2', count: 8, total: 150000 },
|
||||
{ stageId: '3', count: 5, total: 200000 },
|
||||
];
|
||||
|
||||
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(mockOpportunityStats),
|
||||
};
|
||||
|
||||
stageRepo.find.mockResolvedValue(mockStages as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result[0].name).toBe('Prospecting');
|
||||
expect(result[0].opportunityCount).toBe(10);
|
||||
expect(result[0].totalAmount).toBe(100000);
|
||||
expect(stageRepo.find).toHaveBeenCalledWith({
|
||||
where: { tenantId: mockTenantId },
|
||||
order: { position: 'ASC' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return stages with zero counts 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(mockStages as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
result.forEach(stage => {
|
||||
expect(stage.opportunityCount).toBe(0);
|
||||
expect(stage.totalAmount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array when no stages exist', 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([]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.findAll(mockTenantId);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('should return a stage by id', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.findOne(mockTenantId, mockStageId);
|
||||
|
||||
expect(result.id).toBe(mockStageId);
|
||||
expect(result.name).toBe('Qualification');
|
||||
expect(stageRepo.findOne).toHaveBeenCalledWith({
|
||||
where: { id: mockStageId, tenantId: mockTenantId },
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.findOne(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a new pipeline stage', async () => {
|
||||
const createDto = {
|
||||
name: 'New Stage',
|
||||
color: '#FF5733',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
};
|
||||
|
||||
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, ...createDto, position: 6 } as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, position: 6 } as PipelineStageEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, createDto);
|
||||
|
||||
expect(result.name).toBe('New Stage');
|
||||
expect(result.color).toBe('#FF5733');
|
||||
expect(stageRepo.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
tenantId: mockTenantId,
|
||||
name: 'New Stage',
|
||||
position: 6,
|
||||
}));
|
||||
});
|
||||
|
||||
it('should create stage with custom position', async () => {
|
||||
const createDto = {
|
||||
name: 'Custom Stage',
|
||||
position: 3,
|
||||
};
|
||||
|
||||
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, ...createDto } as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto } as PipelineStageEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, createDto);
|
||||
|
||||
expect(result.position).toBe(3);
|
||||
});
|
||||
|
||||
it('should create stage with default values', async () => {
|
||||
const createDto = {
|
||||
name: 'Basic Stage',
|
||||
};
|
||||
|
||||
const mockQueryBuilder = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
getRawOne: jest.fn().mockResolvedValue({ max: null }),
|
||||
};
|
||||
|
||||
stageRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
stageRepo.create.mockReturnValue({
|
||||
...createDto,
|
||||
tenantId: mockTenantId,
|
||||
position: 1,
|
||||
color: '#3B82F6',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
} as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({
|
||||
...createDto,
|
||||
tenantId: mockTenantId,
|
||||
position: 1,
|
||||
color: '#3B82F6',
|
||||
isWon: false,
|
||||
isLost: false,
|
||||
isActive: true,
|
||||
} as PipelineStageEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, createDto);
|
||||
|
||||
expect(result.color).toBe('#3B82F6');
|
||||
expect(result.isWon).toBe(false);
|
||||
expect(result.isLost).toBe(false);
|
||||
expect(result.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should create won stage', async () => {
|
||||
const createDto = {
|
||||
name: 'Won',
|
||||
isWon: true,
|
||||
color: '#10B981',
|
||||
};
|
||||
|
||||
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, ...createDto, isWon: true } as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, isWon: true } as PipelineStageEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, createDto);
|
||||
|
||||
expect(result.isWon).toBe(true);
|
||||
});
|
||||
|
||||
it('should create lost stage', async () => {
|
||||
const createDto = {
|
||||
name: 'Lost',
|
||||
isLost: true,
|
||||
color: '#EF4444',
|
||||
};
|
||||
|
||||
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, ...createDto, isLost: true } as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue({ ...mockStage, ...createDto, isLost: true } as PipelineStageEntity);
|
||||
|
||||
const result = await service.create(mockTenantId, createDto);
|
||||
|
||||
expect(result.isLost).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update a pipeline stage', async () => {
|
||||
const updateDto = {
|
||||
name: 'Updated Stage',
|
||||
color: '#FF0000',
|
||||
};
|
||||
|
||||
const updatedStage = { ...mockStage, ...updateDto };
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockStageId, updateDto);
|
||||
|
||||
expect(result.name).toBe('Updated Stage');
|
||||
expect(result.color).toBe('#FF0000');
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.update(mockTenantId, 'non-existent-id', { name: 'Test' }))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should update stage position', async () => {
|
||||
const updateDto = {
|
||||
position: 4,
|
||||
};
|
||||
|
||||
const updatedStage = { ...mockStage, position: 4 };
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockStageId, updateDto);
|
||||
|
||||
expect(result.position).toBe(4);
|
||||
});
|
||||
|
||||
it('should update isActive status', async () => {
|
||||
const updateDto = {
|
||||
isActive: false,
|
||||
};
|
||||
|
||||
const updatedStage = { ...mockStage, isActive: false };
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
stageRepo.save.mockResolvedValue(updatedStage as PipelineStageEntity);
|
||||
|
||||
const result = await service.update(mockTenantId, mockStageId, updateDto);
|
||||
|
||||
expect(result.isActive).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should delete a pipeline stage without opportunities', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
opportunityRepo.count.mockResolvedValue(0);
|
||||
|
||||
await service.remove(mockTenantId, mockStageId);
|
||||
|
||||
expect(stageRepo.remove).toHaveBeenCalledWith(mockStage);
|
||||
});
|
||||
|
||||
it('should throw NotFoundException when stage not found', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.remove(mockTenantId, 'non-existent-id'))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('should throw error when stage has opportunities', async () => {
|
||||
stageRepo.findOne.mockResolvedValue(mockStage as PipelineStageEntity);
|
||||
opportunityRepo.count.mockResolvedValue(5);
|
||||
|
||||
await expect(service.remove(mockTenantId, mockStageId))
|
||||
.rejects.toThrow(NotFoundException);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorder', () => {
|
||||
it('should reorder pipeline stages', async () => {
|
||||
const reorderDto = {
|
||||
stageIds: ['3', '1', '2', '4', '5', '6'],
|
||||
};
|
||||
|
||||
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([] as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.reorder(mockTenantId, reorderDto);
|
||||
|
||||
expect(stageRepo.save).toHaveBeenCalled();
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('should skip non-existent stage ids', async () => {
|
||||
const reorderDto = {
|
||||
stageIds: ['1', 'non-existent', '2'],
|
||||
};
|
||||
|
||||
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.slice(0, 2) as PipelineStageEntity[]);
|
||||
stageRepo.save.mockResolvedValue([] as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
await service.reorder(mockTenantId, reorderDto);
|
||||
|
||||
expect(stageRepo.save).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: '1', position: 1 }),
|
||||
expect.objectContaining({ id: '2', position: 3 }),
|
||||
])
|
||||
);
|
||||
});
|
||||
|
||||
it('should return updated stages after reorder', async () => {
|
||||
const reorderDto = {
|
||||
stageIds: ['2', '1', '3'],
|
||||
};
|
||||
|
||||
const reorderedStages = [
|
||||
{ ...mockStages[1], position: 1 },
|
||||
{ ...mockStages[0], position: 2 },
|
||||
{ ...mockStages[2], position: 3 },
|
||||
];
|
||||
|
||||
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
|
||||
.mockResolvedValueOnce(mockStages.slice(0, 3) as PipelineStageEntity[])
|
||||
.mockResolvedValueOnce(reorderedStages as PipelineStageEntity[]);
|
||||
stageRepo.save.mockResolvedValue([] as PipelineStageEntity[]);
|
||||
opportunityRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder as any);
|
||||
|
||||
const result = await service.reorder(mockTenantId, reorderDto);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user