test(api): add unit tests for MCP, Inquiries, and Leads modules

Increase test file coverage to ≥50% for three under-tested modules:

- MCP: +1 test (mcp.module.spec.ts) → 2/2 files covered (100%)
- Inquiries: +4 tests (events, repository contract, prisma repo, DTOs)
  → 10/18 files covered (55.6%)
- Leads: +4 tests (events, repository contract, prisma repo, DTOs)
  → 12/22 files covered (54.5%)

All 225 test files pass with 1353 tests total.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 01:47:25 +07:00
parent 40832a9d12
commit f8f2935f45
9 changed files with 1102 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
import { LeadCreatedEvent } from '../events/lead-created.event';
import { LeadStatusChangedEvent } from '../events/lead-status-changed.event';
describe('LeadCreatedEvent', () => {
it('stores the aggregate id and agent id', () => {
const event = new LeadCreatedEvent('lead-1', 'agent-1');
expect(event.aggregateId).toBe('lead-1');
expect(event.agentId).toBe('agent-1');
});
it('has the correct event name', () => {
const event = new LeadCreatedEvent('lead-1', 'agent-1');
expect(event.eventName).toBe('lead.created');
});
it('records the occurred timestamp', () => {
const before = new Date();
const event = new LeadCreatedEvent('lead-1', 'agent-1');
const after = new Date();
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
});
describe('LeadStatusChangedEvent', () => {
it('stores all properties correctly', () => {
const event = new LeadStatusChangedEvent('lead-1', 'agent-1', 'NEW', 'CONTACTED');
expect(event.aggregateId).toBe('lead-1');
expect(event.agentId).toBe('agent-1');
expect(event.oldStatus).toBe('NEW');
expect(event.newStatus).toBe('CONTACTED');
});
it('has the correct event name', () => {
const event = new LeadStatusChangedEvent('lead-1', 'agent-1', 'NEW', 'CONTACTED');
expect(event.eventName).toBe('lead.status_changed');
});
it('records the occurred timestamp', () => {
const before = new Date();
const event = new LeadStatusChangedEvent('lead-1', 'agent-1', 'NEW', 'CONTACTED');
const after = new Date();
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
});
it('captures different status transitions', () => {
const event = new LeadStatusChangedEvent('lead-1', 'agent-1', 'QUALIFIED', 'NEGOTIATING');
expect(event.oldStatus).toBe('QUALIFIED');
expect(event.newStatus).toBe('NEGOTIATING');
});
});

View File

@@ -0,0 +1,18 @@
import { LEAD_REPOSITORY } from '../repositories/lead.repository';
describe('Lead Repository Contract', () => {
describe('LEAD_REPOSITORY symbol', () => {
it('is a unique symbol', () => {
expect(typeof LEAD_REPOSITORY).toBe('symbol');
});
it('has a descriptive string representation', () => {
expect(LEAD_REPOSITORY.toString()).toBe('Symbol(LEAD_REPOSITORY)');
});
it('is not equal to another symbol with the same description', () => {
const anotherSymbol = Symbol('LEAD_REPOSITORY');
expect(LEAD_REPOSITORY).not.toBe(anotherSymbol);
});
});
});

View File

@@ -0,0 +1,331 @@
import { LeadEntity } from '../../domain/entities/lead.entity';
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
import { PrismaLeadRepository } from '../repositories/prisma-lead.repository';
describe('PrismaLeadRepository', () => {
let repository: PrismaLeadRepository;
let mockPrisma: {
lead: {
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
mockPrisma = {
lead: {
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findMany: vi.fn(),
count: vi.fn(),
},
};
repository = new PrismaLeadRepository(mockPrisma as any);
});
describe('findById', () => {
it('returns a LeadEntity when found', async () => {
const now = new Date();
mockPrisma.lead.findUnique.mockResolvedValue({
id: 'lead-1',
agentId: 'agent-1',
name: 'Nguyễn Văn A',
phone: '0901234567',
email: 'a@example.com',
source: 'website',
score: 80,
notes: { interest: 'căn hộ' },
status: 'NEW',
createdAt: now,
updatedAt: now,
});
const result = await repository.findById('lead-1');
expect(result).toBeInstanceOf(LeadEntity);
expect(result!.id).toBe('lead-1');
expect(result!.agentId).toBe('agent-1');
expect(result!.name).toBe('Nguyễn Văn A');
expect(result!.phone).toBe('0901234567');
expect(result!.email).toBe('a@example.com');
expect(result!.source).toBe('website');
expect(result!.score!.value).toBe(80);
expect(result!.status).toBe('NEW');
});
it('returns a LeadEntity with null score', async () => {
const now = new Date();
mockPrisma.lead.findUnique.mockResolvedValue({
id: 'lead-2',
agentId: 'agent-1',
name: 'Trần Thị B',
phone: '0902345678',
email: null,
source: 'referral',
score: null,
notes: null,
status: 'CONTACTED',
createdAt: now,
updatedAt: now,
});
const result = await repository.findById('lead-2');
expect(result!.score).toBeNull();
expect(result!.email).toBeNull();
});
it('returns null when not found', async () => {
mockPrisma.lead.findUnique.mockResolvedValue(null);
const result = await repository.findById('nonexistent');
expect(result).toBeNull();
});
});
describe('save', () => {
it('creates a Prisma record from the entity', async () => {
mockPrisma.lead.create.mockResolvedValue(undefined);
const score = LeadScore.create(80).unwrap();
const entity = LeadEntity.createNew(
'lead-1',
'agent-1',
'Nguyễn Văn A',
'0901234567',
'a@example.com',
'website',
score,
{ interest: 'căn hộ' },
);
await repository.save(entity);
expect(mockPrisma.lead.create).toHaveBeenCalledWith({
data: {
id: 'lead-1',
agentId: 'agent-1',
name: 'Nguyễn Văn A',
phone: '0901234567',
email: 'a@example.com',
source: 'website',
score: 80,
notes: { interest: 'căn hộ' },
status: 'NEW',
},
});
});
it('saves entity with null score and email', async () => {
mockPrisma.lead.create.mockResolvedValue(undefined);
const entity = LeadEntity.createNew(
'lead-2',
'agent-1',
'Trần Thị B',
'0902345678',
null,
'referral',
null,
null,
);
await repository.save(entity);
const callData = mockPrisma.lead.create.mock.calls[0]![0].data;
expect(callData.score).toBeNull();
expect(callData.email).toBeNull();
});
});
describe('update', () => {
it('updates the entity status and score', async () => {
mockPrisma.lead.update.mockResolvedValue(undefined);
const entity = LeadEntity.createNew(
'lead-1',
'agent-1',
'Test',
'0900000000',
null,
'website',
null,
null,
);
await repository.update(entity);
expect(mockPrisma.lead.update).toHaveBeenCalledWith({
where: { id: 'lead-1' },
data: {
status: 'NEW',
score: null,
notes: null,
},
});
});
});
describe('delete', () => {
it('deletes the lead by id', async () => {
mockPrisma.lead.delete.mockResolvedValue(undefined);
await repository.delete('lead-1');
expect(mockPrisma.lead.delete).toHaveBeenCalledWith({
where: { id: 'lead-1' },
});
});
});
describe('findByAgent', () => {
it('returns paginated results', async () => {
const now = new Date();
mockPrisma.lead.findMany.mockResolvedValue([
{
id: 'lead-1',
agentId: 'agent-1',
name: 'Nguyễn Văn A',
phone: '0901234567',
email: 'a@example.com',
source: 'website',
score: 80,
notes: null,
status: 'NEW',
createdAt: now,
updatedAt: now,
},
]);
mockPrisma.lead.count.mockResolvedValue(1);
const result = await repository.findByAgent('agent-1', null, 1, 20);
expect(result.data).toHaveLength(1);
expect(result.total).toBe(1);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(result.totalPages).toBe(1);
expect(result.data[0]!.name).toBe('Nguyễn Văn A');
});
it('filters by status when provided', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
mockPrisma.lead.count.mockResolvedValue(0);
await repository.findByAgent('agent-1', 'NEW', 1, 20);
expect(mockPrisma.lead.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { agentId: 'agent-1', status: 'NEW' },
}),
);
});
it('does not include status filter when null', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
mockPrisma.lead.count.mockResolvedValue(0);
await repository.findByAgent('agent-1', null, 1, 20);
expect(mockPrisma.lead.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { agentId: 'agent-1' },
}),
);
});
it('caps limit at 100', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
mockPrisma.lead.count.mockResolvedValue(0);
const result = await repository.findByAgent('agent-1', null, 1, 200);
expect(result.limit).toBe(100);
expect(mockPrisma.lead.findMany).toHaveBeenCalledWith(
expect.objectContaining({ take: 100 }),
);
});
it('calculates correct skip for pagination', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
mockPrisma.lead.count.mockResolvedValue(0);
await repository.findByAgent('agent-1', null, 3, 10);
expect(mockPrisma.lead.findMany).toHaveBeenCalledWith(
expect.objectContaining({ skip: 20 }),
);
});
it('calculates totalPages correctly', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
mockPrisma.lead.count.mockResolvedValue(45);
const result = await repository.findByAgent('agent-1', null, 1, 20);
expect(result.totalPages).toBe(3);
});
});
describe('getStatsByAgent', () => {
it('returns stats with conversion rate and avg score', async () => {
mockPrisma.lead.findMany.mockResolvedValue([
{ status: 'NEW', score: 60 },
{ status: 'CONTACTED', score: 80 },
{ status: 'CONVERTED', score: 90 },
{ status: 'LOST', score: 30 },
]);
const result = await repository.getStatsByAgent('agent-1');
expect(result.totalLeads).toBe(4);
expect(result.byStatus).toEqual({
NEW: 1,
CONTACTED: 1,
CONVERTED: 1,
LOST: 1,
});
expect(result.conversionRate).toBe(25);
expect(result.avgScore).toBe(65);
});
it('returns zero conversion rate for empty leads', async () => {
mockPrisma.lead.findMany.mockResolvedValue([]);
const result = await repository.getStatsByAgent('agent-1');
expect(result.totalLeads).toBe(0);
expect(result.byStatus).toEqual({});
expect(result.conversionRate).toBe(0);
expect(result.avgScore).toBeNull();
});
it('returns null avgScore when no leads have scores', async () => {
mockPrisma.lead.findMany.mockResolvedValue([
{ status: 'NEW', score: null },
{ status: 'CONTACTED', score: null },
]);
const result = await repository.getStatsByAgent('agent-1');
expect(result.totalLeads).toBe(2);
expect(result.avgScore).toBeNull();
});
it('calculates avgScore only from scored leads', async () => {
mockPrisma.lead.findMany.mockResolvedValue([
{ status: 'NEW', score: 60 },
{ status: 'CONTACTED', score: null },
{ status: 'QUALIFIED', score: 80 },
]);
const result = await repository.getStatsByAgent('agent-1');
expect(result.avgScore).toBe(70);
});
});
});

View File

@@ -0,0 +1,173 @@
import 'reflect-metadata';
import { CreateLeadDto } from '../dto/create-lead.dto';
import { ListLeadsDto } from '../dto/list-leads.dto';
import { UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
describe('CreateLeadDto', () => {
describe('validation metadata', () => {
it('has name property metadata with Vietnamese description', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
CreateLeadDto.prototype,
'name',
);
expect(metadata).toBeDefined();
expect(metadata.description).toBe('Tên khách hàng tiềm năng');
expect(metadata.example).toBe('Nguyễn Văn A');
});
it('has phone property metadata', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
CreateLeadDto.prototype,
'phone',
);
expect(metadata).toBeDefined();
expect(metadata.example).toBe('0901234567');
});
it('has email as optional property', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
CreateLeadDto.prototype,
'email',
);
expect(metadata).toBeDefined();
expect(metadata.required).toBeFalsy();
});
it('has score property with range description', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
CreateLeadDto.prototype,
'score',
);
expect(metadata).toBeDefined();
expect(metadata.description).toBe('Điểm lead (0-100)');
expect(metadata.example).toBe(75);
});
});
describe('property assignment', () => {
it('can assign all required properties', () => {
const dto = new CreateLeadDto();
dto.name = 'Nguyễn Văn A';
dto.phone = '0901234567';
dto.source = 'website';
expect(dto.name).toBe('Nguyễn Văn A');
expect(dto.phone).toBe('0901234567');
expect(dto.source).toBe('website');
});
it('can assign optional email and score', () => {
const dto = new CreateLeadDto();
dto.email = 'a@example.com';
dto.score = 80;
expect(dto.email).toBe('a@example.com');
expect(dto.score).toBe(80);
});
it('optional fields default to undefined', () => {
const dto = new CreateLeadDto();
expect(dto.email).toBeUndefined();
expect(dto.score).toBeUndefined();
expect(dto.notes).toBeUndefined();
});
});
});
describe('ListLeadsDto', () => {
describe('validation metadata', () => {
it('has status property with enum values', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
ListLeadsDto.prototype,
'status',
);
expect(metadata).toBeDefined();
expect(metadata.enum).toEqual([
'NEW',
'CONTACTED',
'QUALIFIED',
'NEGOTIATING',
'CONVERTED',
'LOST',
]);
});
it('has page property with default 1', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
ListLeadsDto.prototype,
'page',
);
expect(metadata).toBeDefined();
expect(metadata.default).toBe(1);
});
it('has limit property with default 20', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
ListLeadsDto.prototype,
'limit',
);
expect(metadata).toBeDefined();
expect(metadata.default).toBe(20);
});
});
describe('property assignment', () => {
it('can assign all filter properties', () => {
const dto = new ListLeadsDto();
dto.status = 'NEW';
dto.page = 2;
dto.limit = 50;
expect(dto.status).toBe('NEW');
expect(dto.page).toBe(2);
expect(dto.limit).toBe(50);
});
it('all properties default to undefined', () => {
const dto = new ListLeadsDto();
expect(dto.status).toBeUndefined();
expect(dto.page).toBeUndefined();
expect(dto.limit).toBeUndefined();
});
});
});
describe('UpdateLeadStatusDto', () => {
describe('validation metadata', () => {
it('has status property with enum values', () => {
const metadata = Reflect.getMetadata(
'swagger/apiModelProperties',
UpdateLeadStatusDto.prototype,
'status',
);
expect(metadata).toBeDefined();
expect(metadata.enum).toEqual([
'NEW',
'CONTACTED',
'QUALIFIED',
'NEGOTIATING',
'CONVERTED',
'LOST',
]);
expect(metadata.example).toBe('CONTACTED');
});
});
describe('property assignment', () => {
it('can assign status', () => {
const dto = new UpdateLeadStatusDto();
dto.status = 'CONTACTED';
expect(dto.status).toBe('CONTACTED');
});
});
});