From f8f2935f45487a31344bf943807007f16b42de1e Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 11 Apr 2026 01:47:25 +0700 Subject: [PATCH] test(api): add unit tests for MCP, Inquiries, and Leads modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../domain/__tests__/inquiry-events.spec.ts | 52 +++ .../inquiry-repository-contract.spec.ts | 18 + .../prisma-inquiry.repository.spec.ts | 233 ++++++++++++ .../__tests__/inquiry-dto.spec.ts | 103 ++++++ .../domain/__tests__/lead-events.spec.ts | 59 ++++ .../lead-repository-contract.spec.ts | 18 + .../__tests__/prisma-lead.repository.spec.ts | 331 ++++++++++++++++++ .../presentation/__tests__/lead-dto.spec.ts | 173 +++++++++ .../modules/mcp/__tests__/mcp.module.spec.ts | 115 ++++++ 9 files changed, 1102 insertions(+) create mode 100644 apps/api/src/modules/inquiries/domain/__tests__/inquiry-events.spec.ts create mode 100644 apps/api/src/modules/inquiries/domain/__tests__/inquiry-repository-contract.spec.ts create mode 100644 apps/api/src/modules/inquiries/infrastructure/__tests__/prisma-inquiry.repository.spec.ts create mode 100644 apps/api/src/modules/inquiries/presentation/__tests__/inquiry-dto.spec.ts create mode 100644 apps/api/src/modules/leads/domain/__tests__/lead-events.spec.ts create mode 100644 apps/api/src/modules/leads/domain/__tests__/lead-repository-contract.spec.ts create mode 100644 apps/api/src/modules/leads/infrastructure/__tests__/prisma-lead.repository.spec.ts create mode 100644 apps/api/src/modules/leads/presentation/__tests__/lead-dto.spec.ts create mode 100644 apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts diff --git a/apps/api/src/modules/inquiries/domain/__tests__/inquiry-events.spec.ts b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-events.spec.ts new file mode 100644 index 0000000..5822eeb --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-events.spec.ts @@ -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()); + }); +}); diff --git a/apps/api/src/modules/inquiries/domain/__tests__/inquiry-repository-contract.spec.ts b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-repository-contract.spec.ts new file mode 100644 index 0000000..fe2ad6d --- /dev/null +++ b/apps/api/src/modules/inquiries/domain/__tests__/inquiry-repository-contract.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/inquiries/infrastructure/__tests__/prisma-inquiry.repository.spec.ts b/apps/api/src/modules/inquiries/infrastructure/__tests__/prisma-inquiry.repository.spec.ts new file mode 100644 index 0000000..77c465e --- /dev/null +++ b/apps/api/src/modules/inquiries/infrastructure/__tests__/prisma-inquiry.repository.spec.ts @@ -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; + create: ReturnType; + update: ReturnType; + findMany: ReturnType; + count: ReturnType; + }; + }; + + 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); + }); + }); +}); diff --git a/apps/api/src/modules/inquiries/presentation/__tests__/inquiry-dto.spec.ts b/apps/api/src/modules/inquiries/presentation/__tests__/inquiry-dto.spec.ts new file mode 100644 index 0000000..4ae64eb --- /dev/null +++ b/apps/api/src/modules/inquiries/presentation/__tests__/inquiry-dto.spec.ts @@ -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(); + }); + }); +}); diff --git a/apps/api/src/modules/leads/domain/__tests__/lead-events.spec.ts b/apps/api/src/modules/leads/domain/__tests__/lead-events.spec.ts new file mode 100644 index 0000000..4920c9b --- /dev/null +++ b/apps/api/src/modules/leads/domain/__tests__/lead-events.spec.ts @@ -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'); + }); +}); diff --git a/apps/api/src/modules/leads/domain/__tests__/lead-repository-contract.spec.ts b/apps/api/src/modules/leads/domain/__tests__/lead-repository-contract.spec.ts new file mode 100644 index 0000000..320a4b1 --- /dev/null +++ b/apps/api/src/modules/leads/domain/__tests__/lead-repository-contract.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/leads/infrastructure/__tests__/prisma-lead.repository.spec.ts b/apps/api/src/modules/leads/infrastructure/__tests__/prisma-lead.repository.spec.ts new file mode 100644 index 0000000..f8d4795 --- /dev/null +++ b/apps/api/src/modules/leads/infrastructure/__tests__/prisma-lead.repository.spec.ts @@ -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; + create: ReturnType; + update: ReturnType; + delete: ReturnType; + findMany: ReturnType; + count: ReturnType; + }; + }; + + 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); + }); + }); +}); diff --git a/apps/api/src/modules/leads/presentation/__tests__/lead-dto.spec.ts b/apps/api/src/modules/leads/presentation/__tests__/lead-dto.spec.ts new file mode 100644 index 0000000..75d9ddf --- /dev/null +++ b/apps/api/src/modules/leads/presentation/__tests__/lead-dto.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts b/apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts new file mode 100644 index 0000000..69fca19 --- /dev/null +++ b/apps/api/src/modules/mcp/__tests__/mcp.module.spec.ts @@ -0,0 +1,115 @@ +import { McpIntegrationModule } from '../mcp.module'; + +describe('McpIntegrationModule', () => { + let module: McpIntegrationModule; + let mockTypesenseClient: { + getClient: ReturnType; + }; + let mockMcpRegistry: { + setTypesenseClient: ReturnType; + onModuleInit: ReturnType; + getServerNames: ReturnType; + }; + let mockLogger: { + log: ReturnType; + warn: ReturnType; + error: ReturnType; + debug: ReturnType; + verbose: ReturnType; + }; + + 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); + }); + }); +});