[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:
Adrian Flores Cortes 2026-01-27 12:53:39 -06:00
parent a881c5cc2b
commit 5b0e61c029
12 changed files with 5810 additions and 0 deletions

View File

@ -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: [],

View File

@ -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;

View 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();
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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');
});
});
});

View 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,
});
});
});
});

View 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,
});
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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);
});
});
});

View 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();
});
});
});