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:
@@ -0,0 +1,52 @@
|
||||
import { InquiryCreatedEvent } from '../events/inquiry-created.event';
|
||||
import { InquiryReadEvent } from '../events/inquiry-read.event';
|
||||
|
||||
describe('InquiryCreatedEvent', () => {
|
||||
it('stores the aggregate id, listing id, and user id', () => {
|
||||
const event = new InquiryCreatedEvent('inq-1', 'listing-1', 'user-1');
|
||||
|
||||
expect(event.aggregateId).toBe('inq-1');
|
||||
expect(event.listingId).toBe('listing-1');
|
||||
expect(event.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('has the correct event name', () => {
|
||||
const event = new InquiryCreatedEvent('inq-1', 'listing-1', 'user-1');
|
||||
|
||||
expect(event.eventName).toBe('inquiry.created');
|
||||
});
|
||||
|
||||
it('records the occurred timestamp', () => {
|
||||
const before = new Date();
|
||||
const event = new InquiryCreatedEvent('inq-1', 'listing-1', 'user-1');
|
||||
const after = new Date();
|
||||
|
||||
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
|
||||
describe('InquiryReadEvent', () => {
|
||||
it('stores the aggregate id, listing id, and user id', () => {
|
||||
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-1');
|
||||
|
||||
expect(event.aggregateId).toBe('inq-1');
|
||||
expect(event.listingId).toBe('listing-1');
|
||||
expect(event.userId).toBe('user-1');
|
||||
});
|
||||
|
||||
it('has the correct event name', () => {
|
||||
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-1');
|
||||
|
||||
expect(event.eventName).toBe('inquiry.read');
|
||||
});
|
||||
|
||||
it('records the occurred timestamp', () => {
|
||||
const before = new Date();
|
||||
const event = new InquiryReadEvent('inq-1', 'listing-1', 'user-1');
|
||||
const after = new Date();
|
||||
|
||||
expect(event.occurredAt.getTime()).toBeGreaterThanOrEqual(before.getTime());
|
||||
expect(event.occurredAt.getTime()).toBeLessThanOrEqual(after.getTime());
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { INQUIRY_REPOSITORY } from '../repositories/inquiry.repository';
|
||||
|
||||
describe('Inquiry Repository Contract', () => {
|
||||
describe('INQUIRY_REPOSITORY symbol', () => {
|
||||
it('is a unique symbol', () => {
|
||||
expect(typeof INQUIRY_REPOSITORY).toBe('symbol');
|
||||
});
|
||||
|
||||
it('has a descriptive string representation', () => {
|
||||
expect(INQUIRY_REPOSITORY.toString()).toBe('Symbol(INQUIRY_REPOSITORY)');
|
||||
});
|
||||
|
||||
it('is not equal to another symbol with the same description', () => {
|
||||
const anotherSymbol = Symbol('INQUIRY_REPOSITORY');
|
||||
expect(INQUIRY_REPOSITORY).not.toBe(anotherSymbol);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,233 @@
|
||||
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
|
||||
import { PrismaInquiryRepository } from '../repositories/prisma-inquiry.repository';
|
||||
|
||||
describe('PrismaInquiryRepository', () => {
|
||||
let repository: PrismaInquiryRepository;
|
||||
let mockPrisma: {
|
||||
inquiry: {
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
count: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockPrisma = {
|
||||
inquiry: {
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
findMany: vi.fn(),
|
||||
count: vi.fn(),
|
||||
},
|
||||
};
|
||||
repository = new PrismaInquiryRepository(mockPrisma as any);
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('returns an InquiryEntity when found', async () => {
|
||||
const now = new Date();
|
||||
mockPrisma.inquiry.findUnique.mockResolvedValue({
|
||||
id: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
userId: 'user-1',
|
||||
message: 'Tôi muốn xem nhà',
|
||||
phone: '0901234567',
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const result = await repository.findById('inq-1');
|
||||
|
||||
expect(result).toBeInstanceOf(InquiryEntity);
|
||||
expect(result!.id).toBe('inq-1');
|
||||
expect(result!.listingId).toBe('listing-1');
|
||||
expect(result!.userId).toBe('user-1');
|
||||
expect(result!.message).toBe('Tôi muốn xem nhà');
|
||||
expect(result!.phone).toBe('0901234567');
|
||||
expect(result!.isRead).toBe(false);
|
||||
});
|
||||
|
||||
it('returns null when not found', async () => {
|
||||
mockPrisma.inquiry.findUnique.mockResolvedValue(null);
|
||||
|
||||
const result = await repository.findById('nonexistent');
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('creates a Prisma record from the entity', async () => {
|
||||
mockPrisma.inquiry.create.mockResolvedValue(undefined);
|
||||
const entity = InquiryEntity.createNew(
|
||||
'inq-1',
|
||||
'listing-1',
|
||||
'user-1',
|
||||
'Tôi muốn xem nhà',
|
||||
'0901234567',
|
||||
);
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
expect(mockPrisma.inquiry.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
id: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
userId: 'user-1',
|
||||
message: 'Tôi muốn xem nhà',
|
||||
phone: '0901234567',
|
||||
isRead: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('saves entity with null phone', async () => {
|
||||
mockPrisma.inquiry.create.mockResolvedValue(undefined);
|
||||
const entity = InquiryEntity.createNew(
|
||||
'inq-2',
|
||||
'listing-1',
|
||||
'user-1',
|
||||
'Cho tôi hỏi giá',
|
||||
null,
|
||||
);
|
||||
|
||||
await repository.save(entity);
|
||||
|
||||
const callData = mockPrisma.inquiry.create.mock.calls[0]![0].data;
|
||||
expect(callData.phone).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('markAsRead', () => {
|
||||
it('updates the isRead field to true', async () => {
|
||||
mockPrisma.inquiry.update.mockResolvedValue(undefined);
|
||||
|
||||
await repository.markAsRead('inq-1');
|
||||
|
||||
expect(mockPrisma.inquiry.update).toHaveBeenCalledWith({
|
||||
where: { id: 'inq-1' },
|
||||
data: { isRead: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByListing', () => {
|
||||
it('returns paginated results', async () => {
|
||||
const now = new Date();
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
userId: 'user-1',
|
||||
message: 'Xin chào',
|
||||
phone: null,
|
||||
isRead: false,
|
||||
createdAt: now,
|
||||
listing: { id: 'listing-1', property: { title: 'Căn hộ Quận 1' } },
|
||||
user: { id: 'user-1', fullName: 'Nguyễn Văn A', phone: '0901234567' },
|
||||
},
|
||||
]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(1);
|
||||
|
||||
const result = await repository.findByListing('listing-1', 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]!.listingTitle).toBe('Căn hộ Quận 1');
|
||||
expect(result.data[0]!.userName).toBe('Nguyễn Văn A');
|
||||
});
|
||||
|
||||
it('caps limit at 100', async () => {
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
||||
|
||||
const result = await repository.findByListing('listing-1', 1, 200);
|
||||
|
||||
expect(result.limit).toBe(100);
|
||||
expect(mockPrisma.inquiry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ take: 100 }),
|
||||
);
|
||||
});
|
||||
|
||||
it('calculates correct skip for pagination', async () => {
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
||||
|
||||
await repository.findByListing('listing-1', 3, 10);
|
||||
|
||||
expect(mockPrisma.inquiry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ skip: 20 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByAgent', () => {
|
||||
it('returns paginated results for agent', async () => {
|
||||
const now = new Date();
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'inq-1',
|
||||
listingId: 'listing-1',
|
||||
userId: 'user-1',
|
||||
message: 'Xin chào',
|
||||
phone: null,
|
||||
isRead: true,
|
||||
createdAt: now,
|
||||
listing: { id: 'listing-1', property: { title: 'Nhà phố Quận 7' } },
|
||||
user: { id: 'user-1', fullName: 'Trần Thị B', phone: '0902345678' },
|
||||
},
|
||||
]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(1);
|
||||
|
||||
const result = await repository.findByAgent('agent-1', 1, 20);
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]!.listingTitle).toBe('Nhà phố Quận 7');
|
||||
expect(result.data[0]!.userName).toBe('Trần Thị B');
|
||||
expect(result.data[0]!.isRead).toBe(true);
|
||||
});
|
||||
|
||||
it('queries using listing.agentId filter', async () => {
|
||||
mockPrisma.inquiry.findMany.mockResolvedValue([]);
|
||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
||||
|
||||
await repository.findByAgent('agent-1', 1, 20);
|
||||
|
||||
expect(mockPrisma.inquiry.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { listing: { agentId: 'agent-1' } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countUnreadByAgent', () => {
|
||||
it('returns the count of unread inquiries for the agent', async () => {
|
||||
mockPrisma.inquiry.count.mockResolvedValue(5);
|
||||
|
||||
const result = await repository.countUnreadByAgent('agent-1');
|
||||
|
||||
expect(result).toBe(5);
|
||||
expect(mockPrisma.inquiry.count).toHaveBeenCalledWith({
|
||||
where: {
|
||||
isRead: false,
|
||||
listing: { agentId: 'agent-1' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 0 when no unread inquiries exist', async () => {
|
||||
mockPrisma.inquiry.count.mockResolvedValue(0);
|
||||
|
||||
const result = await repository.countUnreadByAgent('agent-1');
|
||||
|
||||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'reflect-metadata';
|
||||
import { CreateInquiryDto } from '../dto/create-inquiry.dto';
|
||||
import { ListInquiriesDto } from '../dto/list-inquiries.dto';
|
||||
|
||||
describe('CreateInquiryDto', () => {
|
||||
describe('validation metadata', () => {
|
||||
it('has validation metadata on listingId property', () => {
|
||||
const metadata = Reflect.getMetadata(
|
||||
'swagger/apiModelProperties',
|
||||
CreateInquiryDto.prototype,
|
||||
'listingId',
|
||||
);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata.description).toBe('ID of the listing');
|
||||
});
|
||||
|
||||
it('has validation metadata on message property', () => {
|
||||
const metadata = Reflect.getMetadata(
|
||||
'swagger/apiModelProperties',
|
||||
CreateInquiryDto.prototype,
|
||||
'message',
|
||||
);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata.description).toBe('Tin nhắn yêu cầu tư vấn');
|
||||
});
|
||||
|
||||
it('has optional phone property metadata', () => {
|
||||
const metadata = Reflect.getMetadata(
|
||||
'swagger/apiModelProperties',
|
||||
CreateInquiryDto.prototype,
|
||||
'phone',
|
||||
);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata.description).toBe('Số điện thoại liên hệ');
|
||||
});
|
||||
});
|
||||
|
||||
describe('property assignment', () => {
|
||||
it('can assign all required properties', () => {
|
||||
const dto = new CreateInquiryDto();
|
||||
dto.listingId = 'listing-1';
|
||||
dto.message = 'Tôi muốn xem nhà';
|
||||
|
||||
expect(dto.listingId).toBe('listing-1');
|
||||
expect(dto.message).toBe('Tôi muốn xem nhà');
|
||||
});
|
||||
|
||||
it('can assign optional phone', () => {
|
||||
const dto = new CreateInquiryDto();
|
||||
dto.phone = '0901234567';
|
||||
|
||||
expect(dto.phone).toBe('0901234567');
|
||||
});
|
||||
|
||||
it('phone defaults to undefined when not assigned', () => {
|
||||
const dto = new CreateInquiryDto();
|
||||
|
||||
expect(dto.phone).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ListInquiriesDto', () => {
|
||||
describe('validation metadata', () => {
|
||||
it('has page property metadata', () => {
|
||||
const metadata = Reflect.getMetadata(
|
||||
'swagger/apiModelProperties',
|
||||
ListInquiriesDto.prototype,
|
||||
'page',
|
||||
);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata.default).toBe(1);
|
||||
});
|
||||
|
||||
it('has limit property metadata', () => {
|
||||
const metadata = Reflect.getMetadata(
|
||||
'swagger/apiModelProperties',
|
||||
ListInquiriesDto.prototype,
|
||||
'limit',
|
||||
);
|
||||
expect(metadata).toBeDefined();
|
||||
expect(metadata.default).toBe(20);
|
||||
});
|
||||
});
|
||||
|
||||
describe('property assignment', () => {
|
||||
it('can assign page and limit', () => {
|
||||
const dto = new ListInquiriesDto();
|
||||
dto.page = 2;
|
||||
dto.limit = 50;
|
||||
|
||||
expect(dto.page).toBe(2);
|
||||
expect(dto.limit).toBe(50);
|
||||
});
|
||||
|
||||
it('page and limit default to undefined when not assigned', () => {
|
||||
const dto = new ListInquiriesDto();
|
||||
|
||||
expect(dto.page).toBeUndefined();
|
||||
expect(dto.limit).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
115
apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts
Normal file
115
apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { McpIntegrationModule } from '../mcp.module';
|
||||
|
||||
describe('McpIntegrationModule', () => {
|
||||
let module: McpIntegrationModule;
|
||||
let mockTypesenseClient: {
|
||||
getClient: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockMcpRegistry: {
|
||||
setTypesenseClient: ReturnType<typeof vi.fn>;
|
||||
onModuleInit: ReturnType<typeof vi.fn>;
|
||||
getServerNames: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let mockLogger: {
|
||||
log: ReturnType<typeof vi.fn>;
|
||||
warn: ReturnType<typeof vi.fn>;
|
||||
error: ReturnType<typeof vi.fn>;
|
||||
debug: ReturnType<typeof vi.fn>;
|
||||
verbose: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockTypesenseClient = {
|
||||
getClient: vi.fn().mockReturnValue({ collections: () => ({}) }),
|
||||
};
|
||||
mockMcpRegistry = {
|
||||
setTypesenseClient: vi.fn(),
|
||||
onModuleInit: vi.fn().mockResolvedValue(undefined),
|
||||
getServerNames: vi.fn().mockReturnValue(['search', 'listings']),
|
||||
};
|
||||
mockLogger = {
|
||||
log: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
verbose: vi.fn(),
|
||||
};
|
||||
|
||||
module = new McpIntegrationModule(
|
||||
mockTypesenseClient as any,
|
||||
mockMcpRegistry as any,
|
||||
mockLogger as any,
|
||||
);
|
||||
});
|
||||
|
||||
describe('onModuleInit', () => {
|
||||
it('sets the Typesense client on the MCP registry', async () => {
|
||||
const fakeClient = { collections: () => ({}) };
|
||||
mockTypesenseClient.getClient.mockReturnValue(fakeClient);
|
||||
|
||||
await module.onModuleInit();
|
||||
|
||||
expect(mockTypesenseClient.getClient).toHaveBeenCalledOnce();
|
||||
expect(mockMcpRegistry.setTypesenseClient).toHaveBeenCalledWith(fakeClient);
|
||||
});
|
||||
|
||||
it('re-initializes MCP registry after setting Typesense client', async () => {
|
||||
await module.onModuleInit();
|
||||
|
||||
expect(mockMcpRegistry.onModuleInit).toHaveBeenCalledOnce();
|
||||
// setTypesenseClient should be called BEFORE onModuleInit
|
||||
const setClientOrder =
|
||||
mockMcpRegistry.setTypesenseClient.mock.invocationCallOrder[0];
|
||||
const initOrder =
|
||||
mockMcpRegistry.onModuleInit.mock.invocationCallOrder[0];
|
||||
expect(setClientOrder).toBeLessThan(initOrder!);
|
||||
});
|
||||
|
||||
it('logs the initialized server names', async () => {
|
||||
mockMcpRegistry.getServerNames.mockReturnValue(['search', 'listings']);
|
||||
|
||||
await module.onModuleInit();
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
'MCP servers initialized: search, listings',
|
||||
'McpIntegrationModule',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs empty list when no servers are registered', async () => {
|
||||
mockMcpRegistry.getServerNames.mockReturnValue([]);
|
||||
|
||||
await module.onModuleInit();
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
'MCP servers initialized: ',
|
||||
'McpIntegrationModule',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs single server name correctly', async () => {
|
||||
mockMcpRegistry.getServerNames.mockReturnValue(['search']);
|
||||
|
||||
await module.onModuleInit();
|
||||
|
||||
expect(mockLogger.log).toHaveBeenCalledWith(
|
||||
'MCP servers initialized: search',
|
||||
'McpIntegrationModule',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('module metadata', () => {
|
||||
it('has controllers metadata defined', () => {
|
||||
const controllers = Reflect.getMetadata('controllers', McpIntegrationModule);
|
||||
expect(controllers).toBeDefined();
|
||||
expect(controllers).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('has imports metadata defined', () => {
|
||||
const imports = Reflect.getMetadata('imports', McpIntegrationModule);
|
||||
expect(imports).toBeDefined();
|
||||
expect(imports.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user