58 KiB
Báo Cáo Phân Tích Độ Phủ Kiểm Thử
GoodGo Platform API — Các Tệp Nguồn Chưa Được Kiểm Thử
Ngày tạo: 2026-04-11
Thư mục làm việc: /Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/api/
Tóm Tắt Điều Hành
Báo cáo này liệt kê 17 tệp nguồn chưa được kiểm thử trong các module inquiries, leads, và reviews, cùng toàn bộ phần triển khai của chúng. Các tệp được phân thành bốn nhóm:
- Repository Hạ Tầng (3 tệp) - Lớp truy cập dữ liệu Prisma
- Value Object Miền (2 tệp) - Đối tượng xác thực LeadScore và Rating
- DTO Trình Bày (10 tệp) - Các lớp xác thực request/response
- Controller Trình Bày (2 tệp) - Bộ xử lý endpoint HTTP
Hai tệp kiểm thử hiện có được cung cấp như các mẫu tham khảo, minh họa các phương pháp hay nhất cho kiểm thử đơn vị handler và controller.
PHẦN 1: MODULE INQUIRIES
1.1 Prisma Inquiry Repository
Tệp: src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts
import { Injectable } from '@nestjs/common';
import { type Inquiry as PrismaInquiry } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import { type IInquiryRepository, type PaginatedResult } from '../../domain/repositories/inquiry.repository';
@Injectable()
export class PrismaInquiryRepository implements IInquiryRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<InquiryEntity | null> {
const inquiry = await this.prisma.inquiry.findUnique({ where: { id } });
return inquiry ? this.toDomain(inquiry) : null;
}
async save(entity: InquiryEntity): Promise<void> {
await this.prisma.inquiry.create({
data: {
id: entity.id,
listingId: entity.listingId,
userId: entity.userId,
message: entity.message,
phone: entity.phone,
isRead: entity.isRead,
},
});
}
async markAsRead(id: string): Promise<void> {
await this.prisma.inquiry.update({
where: { id },
data: { isRead: true },
});
}
async findByListing(
listingId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listingId };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByAgent(
agentId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listing: { agentId } };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async countUnreadByAgent(agentId: string): Promise<number> {
return this.prisma.inquiry.count({
where: {
isRead: false,
listing: { agentId },
},
});
}
private toDomain(raw: PrismaInquiry): InquiryEntity {
return new InquiryEntity(
raw.id,
{
listingId: raw.listingId,
userId: raw.userId,
message: raw.message,
phone: raw.phone,
isRead: raw.isRead,
},
raw.createdAt,
);
}
}
Các Phương Thức Cần Kiểm Thử:
findById()- Trả về inquiry theo ID hoặc null nếu không tìm thấysave()- Tạo bản ghi inquiry mớimarkAsRead()- Cập nhật cờ isReadfindByListing()- Truy vấn phân trang theo listing, bao gồm các quan hệfindByAgent()- Truy vấn phân trang qua quan hệ agent của listingcountUnreadByAgent()- Truy vấn tổng hợp đếm số lượng chưa đọctoDomain()- Bộ ánh xạ riêng tư chuyển đổi model Prisma sang domain entity
Các Kịch Bản Kiểm Thử:
- Tất cả phương thức với đầu vào hợp lệ
- Trả về null (không có bản ghi phù hợp)
- Các trường hợp biên của phân trang (giới hạn trang, giới hạn số lượng)
- Độ chính xác ánh xạ dữ liệu (ngày ISO, kết nối quan hệ)
1.2 Inquiries Controller
Tệp: src/modules/inquiries/presentation/controllers/inquiries.controller.ts
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
@ApiTags('inquiries')
@Controller('inquiries')
export class InquiriesController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create an inquiry for a listing' })
@ApiResponse({ status: 201, description: 'Inquiry created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@UseGuards(JwtAuthGuard)
@Post()
async createInquiry(
@Body() dto: CreateInquiryDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateInquiryResult> {
return this.commandBus.execute(
new CreateInquiryCommand(
user.sub,
dto.listingId,
dto.message,
dto.phone ?? null,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries by listing' })
@ApiParam({ name: 'listingId', description: 'Listing ID' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('listing/:listingId')
async getByListing(
@Param('listingId') listingId: string,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByListingQuery(
listingId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries for current agent' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries for agent' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not an agent' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Get('agent/me')
async getMyInquiries(
@CurrentUser() user: JwtPayload,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByAgentQuery(
user.sub,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Mark inquiry as read' })
@ApiParam({ name: 'id', description: 'Inquiry ID' })
@ApiResponse({ status: 200, description: 'Inquiry marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not the listing agent' })
@ApiResponse({ status: 404, description: 'Inquiry not found' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Patch(':id/read')
async markAsRead(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ success: boolean }> {
await this.commandBus.execute(
new MarkInquiryReadCommand(id, user.sub),
);
return { success: true };
}
}
Các Endpoint Cần Kiểm Thử:
POST /inquiries- Tạo inquiry với số điện thoại tùy chọnGET /inquiries/listing/:listingId- Danh sách theo listing có phân trangGET /inquiries/agent/me- Danh sách cho agent đã xác thực với guard vai trò AGENTPATCH /inquiries/:id/read- Đánh dấu đã đọc với guard vai trò
Các Kịch Bản Kiểm Thử:
- Gửi lệnh/truy vấn đến bus với các tham số đúng
- Giá trị phân trang mặc định (page: 1, limit: 20)
- Xử lý phone null (chuyển thành null khi không cung cấp)
- Kiểm tra thực thi guard/decorator (JwtAuthGuard, RolesGuard, @Roles('AGENT'))
1.3 Create Inquiry DTO
Tệp: src/modules/inquiries/presentation/dto/create-inquiry.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateInquiryDto {
@ApiProperty({ description: 'ID of the listing' })
@IsString()
@IsNotEmpty()
listingId!: string;
@ApiProperty({ description: 'Tin nhắn yêu cầu tư vấn' })
@IsString()
@IsNotEmpty()
@MaxLength(2000)
message!: string;
@ApiPropertyOptional({ description: 'Số điện thoại liên hệ' })
@IsOptional()
@IsString()
phone?: string;
}
Các Quy Tắc Xác Thực:
listingId- Chuỗi bắt buộcmessage- Chuỗi bắt buộc, tối đa 2000 ký tựphone- Chuỗi tùy chọn
Các Kịch Bản Kiểm Thử:
- Đầu vào hợp lệ với tất cả các trường
- Đầu vào hợp lệ không có phone
- Thiếu các trường bắt buộc
- Message vượt quá 2000 ký tự
1.4 List Inquiries DTO
Tệp: src/modules/inquiries/presentation/dto/list-inquiries.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListInquiriesDto {
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
Các Quy Tắc Xác Thực:
page- Số nguyên tùy chọn, tối thiểu 1limit- Số nguyên tùy chọn, tối thiểu 1, tối đa 100
Các Kịch Bản Kiểm Thử:
- Phân trang hợp lệ (page: 1, limit: 20)
- Phân trang tùy chỉnh
- Không hợp lệ: page < 1, limit < 1, limit > 100
- Chuyển đổi kiểu dữ liệu (chuỗi sang số)
PHẦN 2: MODULE LEADS
2.1 Prisma Lead Repository
Tệp: src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts
import { Injectable } from '@nestjs/common';
import { type Lead as PrismaLead } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
@Injectable()
export class PrismaLeadRepository implements ILeadRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<LeadEntity | null> {
const lead = await this.prisma.lead.findUnique({ where: { id } });
return lead ? this.toDomain(lead) : null;
}
async save(entity: LeadEntity): Promise<void> {
await this.prisma.lead.create({
data: {
id: entity.id,
agentId: entity.agentId,
name: entity.name,
phone: entity.phone,
email: entity.email,
source: entity.source,
score: entity.score?.value ?? null,
notes: entity.notes as never,
status: entity.status,
},
});
}
async update(entity: LeadEntity): Promise<void> {
await this.prisma.lead.update({
where: { id: entity.id },
data: {
status: entity.status,
score: entity.score?.value ?? null,
notes: entity.notes as never,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.lead.delete({ where: { id } });
}
async findByAgent(
agentId: string,
status: string | null,
page: number,
limit: number,
): Promise<PaginatedResult<LeadReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where: Record<string, unknown> = { agentId };
if (status) {
where['status'] = status;
}
const [data, total] = await Promise.all([
this.prisma.lead.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
this.prisma.lead.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
agentId: r.agentId,
name: r.name,
phone: r.phone,
email: r.email,
source: r.source,
score: r.score,
notes: r.notes,
status: r.status,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async getStatsByAgent(agentId: string): Promise<LeadStatsData> {
const leads = await this.prisma.lead.findMany({
where: { agentId },
select: { status: true, score: true },
});
const totalLeads = leads.length;
const byStatus: Record<string, number> = {};
let scoreSum = 0;
let scoreCount = 0;
let convertedCount = 0;
for (const lead of leads) {
byStatus[lead.status] = (byStatus[lead.status] ?? 0) + 1;
if (lead.score !== null) {
scoreSum += lead.score;
scoreCount++;
}
if (lead.status === 'CONVERTED') {
convertedCount++;
}
}
return {
totalLeads,
byStatus,
conversionRate: totalLeads > 0
? Math.round((convertedCount / totalLeads) * 10000) / 100
: 0,
avgScore: scoreCount > 0
? Math.round((scoreSum / scoreCount) * 10) / 10
: null,
};
}
private toDomain(raw: PrismaLead): LeadEntity {
let score: LeadScore | null = null;
if (raw.score !== null) {
score = LeadScore.create(raw.score).unwrap();
}
return new LeadEntity(
raw.id,
{
agentId: raw.agentId,
name: raw.name,
phone: raw.phone,
email: raw.email,
source: raw.source,
score,
notes: raw.notes,
status: raw.status as LeadStatus,
},
raw.createdAt,
raw.updatedAt,
);
}
}
Các Phương Thức Cần Kiểm Thử:
findById()- Trả về lead hoặc nullsave()- Tạo lead với điểm tùy chọnupdate()- Cập nhật trạng thái, điểm, ghi chúdelete()- Xóa leadfindByAgent()- Phân trang với bộ lọc trạng thái tùy chọngetStatsByAgent()- Tổng hợp theo trạng thái, tính tỷ lệ chuyển đổi và điểm trung bìnhtoDomain()- Ánh xạ model Prisma bao gồm khởi tạo LeadScore VO
Các Kịch Bản Kiểm Thử:
- Các thao tác CRUD
- Xử lý điểm tùy chọn (null và giá trị hợp lệ)
- Lọc trạng thái trong truy vấn phân trang
- Tính toán thống kê (không có lead, không có điểm, độ chính xác tỷ lệ chuyển đổi)
- Các trường hợp biên của phân trang
2.2 Lead Score Value Object
Tệp: src/modules/leads/domain/value-objects/lead-score.vo.ts
import { Result, ValueObject } from '@modules/shared';
interface LeadScoreProps {
value: number;
}
export class LeadScore extends ValueObject<LeadScoreProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<LeadScore, string> {
if (value < 0 || value > 100) {
return Result.err('Điểm lead phải từ 0 đến 100');
}
return Result.ok(new LeadScore({ value }));
}
}
Các Quy Tắc Xác Thực:
- Giá trị phải là số nguyên trong khoảng từ 0 đến 100 (bao gồm hai đầu)
Các Kịch Bản Kiểm Thử:
- Điểm hợp lệ (0, 50, 100)
- Không hợp lệ: âm, > 100, null, chuỗi
- Thông báo lỗi bằng tiếng Việt
- Tính bằng nhau của value object (phải có thể so sánh)
2.3 Leads Controller
Tệp: src/modules/leads/presentation/controllers/leads.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command';
import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { type CreateLeadDto } from '../dto/create-lead.dto';
import { type ListLeadsDto } from '../dto/list-leads.dto';
import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
@ApiTags('leads')
@ApiBearerAuth('JWT')
@Controller('leads')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
export class LeadsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiOperation({ summary: 'Tạo lead mới' })
@ApiResponse({ status: 201, description: 'Lead đã được tạo thành công' })
@ApiResponse({ status: 400, description: 'Lỗi validation' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@Post()
async createLead(
@Body() dto: CreateLeadDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateLeadResult> {
return this.commandBus.execute(
new CreateLeadCommand(
user.sub,
dto.name,
dto.phone,
dto.email ?? null,
dto.source,
dto.score ?? null,
dto.notes ?? null,
),
);
}
@ApiOperation({ summary: 'Danh sách lead của agent' })
@ApiResponse({ status: 200, description: 'Danh sách lead phân trang' })
@Get()
async getLeads(
@Query() dto: ListLeadsDto,
@CurrentUser() user: JwtPayload,
): Promise<PaginatedResult<LeadReadDto>> {
return this.queryBus.execute(
new GetLeadsByAgentQuery(
user.sub,
dto.status ?? null,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Thống kê lead của agent' })
@ApiResponse({ status: 200, description: 'Thống kê lead' })
@Get('stats')
async getStats(
@CurrentUser() user: JwtPayload,
): Promise<LeadStatsData> {
return this.queryBus.execute(
new GetLeadStatsQuery(user.sub),
);
}
@ApiOperation({ summary: 'Cập nhật trạng thái lead' })
@ApiParam({ name: 'id', description: 'Lead ID' })
@ApiResponse({ status: 200, description: 'Trạng thái đã được cập nhật' })
@ApiResponse({ status: 400, description: 'Chuyển trạng thái không hợp lệ' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
@Patch(':id/status')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateLeadStatusDto,
@CurrentUser() user: JwtPayload,
): Promise<{ updated: boolean }> {
await this.commandBus.execute(
new UpdateLeadStatusCommand(id, user.sub, dto.status),
);
return { updated: true };
}
@ApiOperation({ summary: 'Xóa lead' })
@ApiParam({ name: 'id', description: 'Lead ID' })
@ApiResponse({ status: 200, description: 'Lead đã được xóa' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
@Delete(':id')
async deleteLead(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
await this.commandBus.execute(new DeleteLeadCommand(id, user.sub));
return { deleted: true };
}
}
Các Endpoint Cần Kiểm Thử:
POST /leads- Tạo lead với email, điểm, ghi chú tùy chọnGET /leads- Danh sách lead của agent với bộ lọc trạng thái tùy chọnGET /leads/stats- Tổng hợp thống kêPATCH /leads/:id/status- Cập nhật trạng thái leadDELETE /leads/:id- Xóa lead
Các Kịch Bản Kiểm Thử:
- Guard cấp lớp (@Roles('AGENT') áp dụng cho tất cả phương thức)
- Xử lý trường tùy chọn (email, score, notes → null)
- Lọc trạng thái (null truyền qua, trạng thái cụ thể lọc kết quả)
- Ánh xạ tham số lệnh/truy vấn
- Xác minh kiểu trả về (updated: true, deleted: true)
2.4 Create Lead DTO
Tệp: src/modules/leads/presentation/dto/create-lead.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateLeadDto {
@ApiProperty({ example: 'Nguyễn Văn A', description: 'Tên khách hàng tiềm năng' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ example: '0901234567', description: 'Số điện thoại' })
@IsString()
@IsNotEmpty()
phone!: string;
@ApiPropertyOptional({ example: 'nguyen@example.com', description: 'Email' })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ example: 'website', description: 'Nguồn lead' })
@IsString()
@IsNotEmpty()
source!: string;
@ApiPropertyOptional({ example: 75, description: 'Điểm lead (0-100)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
score?: number;
@ApiPropertyOptional({ description: 'Ghi chú bổ sung' })
@IsOptional()
notes?: Record<string, unknown>;
}
Các Quy Tắc Xác Thực:
name- Chuỗi bắt buộcphone- Chuỗi bắt buộcemail- Tùy chọn, phải đúng định dạng emailsource- Chuỗi bắt buộcscore- Số tùy chọn, 0-100notes- Đối tượng tùy chọn
Các Kịch Bản Kiểm Thử:
- Tất cả các trường bắt buộc có mặt
- Bỏ qua các trường tùy chọn
- Định dạng email không hợp lệ
- Điểm ngoài phạm vi
- Kiểu notes không hợp lệ
2.5 List Leads DTO
Tệp: src/modules/leads/presentation/dto/list-leads.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
export class ListLeadsDto {
@ApiPropertyOptional({
enum: LEAD_STATUSES,
description: 'Lọc theo trạng thái',
})
@IsOptional()
@IsIn(LEAD_STATUSES)
status?: string;
@ApiPropertyOptional({ example: 1, description: 'Trang', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Số lượng mỗi trang', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
Các Quy Tắc Xác Thực:
status- Tùy chọn, phải là một trong: NEW, CONTACTED, QUALIFIED, NEGOTIATING, CONVERTED, LOSTpage- Số nguyên tùy chọn, tối thiểu 1limit- Số nguyên tùy chọn, 1-100
Các Kịch Bản Kiểm Thử:
- Phân trang hợp lệ
- Giá trị trạng thái hợp lệ
- Trạng thái không hợp lệ
- Chuyển đổi kiểu dữ liệu (chuỗi sang số)
- Kiểm tra biên
2.6 Update Lead Status DTO
Tệp: src/modules/leads/presentation/dto/update-lead-status.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
export class UpdateLeadStatusDto {
@ApiProperty({
enum: LEAD_STATUSES,
description: 'Trạng thái mới của lead',
example: 'CONTACTED',
})
@IsIn(LEAD_STATUSES)
status!: string;
}
Các Quy Tắc Xác Thực:
status- Bắt buộc, phải là trạng thái lead hợp lệ
Các Kịch Bản Kiểm Thử:
- Các chuyển đổi trạng thái hợp lệ
- Trạng thái không hợp lệ
- Thiếu trường status
PHẦN 3: MODULE REVIEWS
3.1 Prisma Review Repository
Tệp: src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts
import { Injectable } from '@nestjs/common';
import { type Review as PrismaReview } from '@prisma/client';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService } from '@modules/shared';
import { ReviewEntity } from '../../domain/entities/review.entity';
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
import { type IReviewRepository, type PaginatedResult } from '../../domain/repositories/review.repository';
import { Rating } from '../../domain/value-objects/rating.vo';
@Injectable()
export class PrismaReviewRepository implements IReviewRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findUnique({ where: { id } });
return review ? this.toDomain(review) : null;
}
async findByUserAndTarget(
userId: string,
targetType: string,
targetId: string,
): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findFirst({
where: { userId, targetType, targetId },
});
return review ? this.toDomain(review) : null;
}
async save(entity: ReviewEntity): Promise<void> {
await this.prisma.review.create({
data: {
id: entity.id,
userId: entity.userId,
targetType: entity.targetType,
targetId: entity.targetId,
rating: entity.rating.value,
comment: entity.comment,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.review.delete({ where: { id } });
}
async findByTarget(
targetType: string,
targetId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { targetType, targetId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByUserId(
userId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { userId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
const reviews = await this.prisma.review.findMany({
where: { targetType, targetId },
select: { rating: true },
});
const totalReviews = reviews.length;
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
let sum = 0;
for (const r of reviews) {
sum += r.rating;
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
}
return {
targetType,
targetId,
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
totalReviews,
distribution,
};
}
private toDomain(raw: PrismaReview): ReviewEntity {
const rating = Rating.create(raw.rating).unwrap();
return new ReviewEntity(
raw.id,
{
userId: raw.userId,
targetType: raw.targetType,
targetId: raw.targetId,
rating,
comment: raw.comment,
},
raw.createdAt,
);
}
}
Các Phương Thức Cần Kiểm Thử:
findById()- Trả về review hoặc nullfindByUserAndTarget()- Kiểm tra ràng buộc duy nhấtsave()- Tạo review với bình luận tùy chọndelete()- Xóa reviewfindByTarget()- Danh sách phân trang với kết nối userfindByUserId()- Danh sách phân trang theo usergetStats()- Tính điểm đánh giá trung bình và phân phốitoDomain()- Ánh xạ model Prisma bao gồm Rating VO
Các Kịch Bản Kiểm Thử:
- Tất cả các thao tác CRUD
- Xử lý bình luận tùy chọn
- Phân trang với quan hệ (kết nối fullName của user)
- Thống kê với tính toán phân phối (thang 1-5)
- Trường hợp biên không có review
- Độ chính xác dữ liệu (ngày ISO, làm tròn thập phân)
3.2 Rating Value Object
Tệp: src/modules/reviews/domain/value-objects/rating.vo.ts
import { Result, ValueObject } from '@modules/shared';
interface RatingProps {
value: number;
}
export class Rating extends ValueObject<RatingProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<Rating, string> {
if (!Number.isInteger(value) || value < 1 || value > 5) {
return Result.err('Đánh giá phải từ 1 đến 5 sao');
}
return Result.ok(new Rating({ value }));
}
}
Các Quy Tắc Xác Thực:
- Phải là số nguyên trong khoảng từ 1 đến 5 (bao gồm hai đầu)
Các Kịch Bản Kiểm Thử:
- Đánh giá hợp lệ (1-5)
- Không hợp lệ: 0, 6, -1, null, số thực (2.5), không phải số nguyên
- Xác minh thông báo lỗi
- Tạo value object và kiểm tra tính bằng nhau
3.3 Create Review DTO
Tệp: src/modules/reviews/presentation/dto/create-review.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateReviewDto {
@ApiProperty({ example: 'agent', description: 'Target entity type (e.g. agent, property)' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiProperty({ example: 5, description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
rating!: number;
@ApiPropertyOptional({ example: 'Dịch vụ rất tốt!', description: 'Optional review comment' })
@IsOptional()
@IsString()
@MaxLength(2000)
comment?: string;
}
Các Quy Tắc Xác Thực:
targetType- Chuỗi bắt buộc (ví dụ: "agent", "property")targetId- Chuỗi bắt buộcrating- Số nguyên bắt buộc, 1-5comment- Chuỗi tùy chọn, tối đa 2000 ký tự
Các Kịch Bản Kiểm Thử:
- Review hợp lệ với tất cả các trường
- Hợp lệ không có bình luận
- Thiếu các trường bắt buộc
- Đánh giá không hợp lệ (0, 6, 2.5)
- Bình luận vượt quá độ dài tối đa
3.4 List Reviews DTOs
Tệp: src/modules/reviews/presentation/dto/list-reviews.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListReviewsByTargetDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
export class ReviewStatsDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
}
Các Quy Tắc Xác Thực của ListReviewsByTargetDto:
targetType- Chuỗi bắt buộctargetId- Chuỗi bắt buộcpage- Tùy chọn, tối thiểu 1limit- Tùy chọn, 1-100
Các Quy Tắc Xác Thực của ReviewStatsDto:
targetType- Chuỗi bắt buộctargetId- Chuỗi bắt buộc
Các Kịch Bản Kiểm Thử:
- Phân trang hợp lệ với các trường target bắt buộc
- Chuyển đổi kiểu dữ liệu
- Kiểm tra biên phân trang
3.5 Reviews Controller
Tệp: src/modules/reviews/presentation/controllers/reviews.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
import { type PaginatedResult } from '../../domain/repositories/review.repository';
import { type CreateReviewDto } from '../dto/create-review.dto';
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
@ApiTags('reviews')
@Controller('reviews')
export class ReviewsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create a review' })
@ApiResponse({ status: 201, description: 'Review created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
@UseGuards(JwtAuthGuard)
@Post()
async createReview(
@Body() dto: CreateReviewDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateReviewResult> {
return this.commandBus.execute(
new CreateReviewCommand(
user.sub,
dto.targetType,
dto.targetId,
dto.rating,
dto.comment ?? null,
),
);
}
@ApiOperation({ summary: 'List reviews by target' })
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
@Get()
async getReviewsByTarget(
@Query() dto: ListReviewsByTargetDto,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByTargetQuery(
dto.targetType,
dto.targetId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
@ApiResponse({ status: 200, description: 'Rating statistics' })
@Get('stats')
async getStats(
@Query() dto: ReviewStatsDto,
): Promise<ReviewStatsData> {
return this.queryBus.execute(
new GetAverageRatingQuery(dto.targetType, dto.targetId),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get reviews by authenticated user' })
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('me')
async getMyReviews(
@CurrentUser() user: JwtPayload,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Delete own review' })
@ApiParam({ name: 'id', description: 'Review ID' })
@ApiResponse({ status: 200, description: 'Review deleted' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
@ApiResponse({ status: 404, description: 'Review not found' })
@UseGuards(JwtAuthGuard)
@Delete(':id')
async deleteReview(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
return { deleted: true };
}
}
Các Endpoint Cần Kiểm Thử:
POST /reviews- Tạo với bình luận tùy chọnGET /reviews- Danh sách theo target (không yêu cầu xác thực)GET /reviews/stats- Thống kê cho target (không yêu cầu xác thực)GET /reviews/me- Danh sách review của user (yêu cầu xác thực)DELETE /reviews/:id- Xóa review (yêu cầu xác thực)
Các Kịch Bản Kiểm Thử:
- Gửi lệnh/truy vấn với các tham số đúng
- Phân trang mặc định (1, 20)
- Xử lý bình luận null
- Yêu cầu xác thực (JWT cho POST, GET /me, DELETE; không xác thực cho GET, GET/stats)
- Kiểu trả về (deleted: true)
PHẦN 4: CÁC MẪU KIỂM THỬ THAM KHẢO
4.1 Ví Dụ Kiểm Thử Handler: Create Inquiry Handler
Tệp: src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts
import type { EventBus } from '@nestjs/cqrs';
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
describe('CreateInquiryHandler', () => {
let handler: CreateInquiryHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateInquiryHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});
it('creates an inquiry successfully', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Tôi muốn xem nhà',
'0901234567',
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.listingId).toBe('listing-1');
expect(result.createdAt).toBeDefined();
expect(mockInquiryRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const command = new CreateInquiryCommand(
'user-1',
'listing-not-exist',
'Tôi muốn xem nhà',
null,
);
await expect(handler.execute(command)).rejects.toThrow(
"Listing with id 'listing-not-exist' not found",
);
expect(mockInquiryRepo.save).not.toHaveBeenCalled();
});
it('publishes domain events after saving', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Cho tôi hỏi giá',
null,
);
await handler.execute(command);
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'inquiry.created',
listingId: 'listing-1',
userId: 'user-1',
}),
);
});
});
Nhận Xét về Mẫu:
- Sử dụng
vi.fn()từ Vitest để mock - Mock tất cả các phương thức repository trong beforeEach
- Kiểm tra đường dẫn thành công với tất cả dependencies đã được mock
- Kiểm tra các kịch bản lỗi (NotFoundException)
- Xác minh các hiệu ứng phụ (publishing sự kiện)
- Sử dụng kiểm tra tham số chính xác hoặc matcher (
expect.objectContaining)
4.2 Ví Dụ Kiểm Thử Handler: Create Lead Handler
Tệp: src/modules/leads/application/__tests__/create-lead.handler.spec.ts
import type { EventBus } from '@nestjs/cqrs';
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
import { CreateLeadHandler } from '../commands/create-lead/create-lead.handler';
describe('CreateLeadHandler', () => {
let handler: CreateLeadHandler;
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockLeadRepo = {
findById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findByAgent: vi.fn(),
getStatsByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateLeadHandler(
mockLeadRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});
it('creates a lead successfully', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockLeadRepo.save.mockResolvedValue(undefined);
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn A',
'0901234567',
'a@example.com',
'WEBSITE',
75,
{ note: 'Interested in District 7' },
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.status).toBe('NEW');
expect(result.createdAt).toBeDefined();
expect(mockLeadRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('creates a lead with null score', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockLeadRepo.save.mockResolvedValue(undefined);
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn B',
'0907654321',
null,
'REFERRAL',
null,
null,
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.status).toBe('NEW');
});
it('throws NotFoundException when agent not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const command = new CreateLeadCommand(
'not-an-agent',
'Nguyễn Văn A',
'0901234567',
null,
'WEBSITE',
null,
null,
);
await expect(handler.execute(command)).rejects.toThrow(
"Agent with id 'not-an-agent' not found",
);
expect(mockLeadRepo.save).not.toHaveBeenCalled();
});
it('throws ValidationException for invalid score', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn A',
'0901234567',
null,
'WEBSITE',
150,
null,
);
await expect(handler.execute(command)).rejects.toThrow(
'Điểm lead phải từ 0 đến 100',
);
expect(mockLeadRepo.save).not.toHaveBeenCalled();
});
});
Ghi Chú Bổ Sung về Mẫu:
- Kiểm tra xử lý tham số tùy chọn (score: null)
- Kiểm tra lỗi xác thực từ value object (LeadScore)
- Sử dụng cấu trúc mock thiết lập giống như inquiry handler
4.3 Ví Dụ Kiểm Thử Controller: Reviews Controller
Tệp: src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import { ReviewsController } from '../controllers/reviews.controller';
describe('ReviewsController', () => {
let controller: ReviewsController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
mockQueryBus = { execute: vi.fn() };
controller = new ReviewsController(mockCommandBus as any, mockQueryBus as any);
});
describe('POST /reviews — createReview', () => {
it('dispatches CreateReviewCommand with correct parameters', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1', rating: 5, comment: 'Tuyệt vời!' };
const expected = { id: 'rev-1', rating: 5, targetType: 'agent', targetId: 'agent-1', createdAt: '2026-01-01T00:00:00.000Z' };
mockCommandBus.execute.mockResolvedValue(expected);
const result = await controller.createReview(dto as any, mockUser as any);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(CreateReviewCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
expect(cmd.userId).toBe('user-1');
expect(cmd.targetType).toBe('agent');
expect(cmd.targetId).toBe('agent-1');
expect(cmd.rating).toBe(5);
expect(cmd.comment).toBe('Tuyệt vời!');
expect(result).toEqual(expected);
});
it('passes null comment when not provided', async () => {
const dto = { targetType: 'property', targetId: 'prop-1', rating: 3 };
mockCommandBus.execute.mockResolvedValue({ id: 'rev-2' });
await controller.createReview(dto as any, mockUser as any);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
expect(cmd.comment).toBeNull();
});
});
describe('GET /reviews — getReviewsByTarget', () => {
it('dispatches GetReviewsByTargetQuery with defaults', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1' };
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getReviewsByTarget(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetReviewsByTargetQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
expect(query.targetType).toBe('agent');
expect(query.targetId).toBe('agent-1');
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
expect(result).toEqual(expected);
});
it('passes custom page and limit', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1', page: 3, limit: 10 };
mockQueryBus.execute.mockResolvedValue({ data: [] });
await controller.getReviewsByTarget(dto as any);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
expect(query.page).toBe(3);
expect(query.limit).toBe(10);
});
});
describe('GET /reviews/stats — getStats', () => {
it('dispatches GetAverageRatingQuery', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1' };
const expected = { targetType: 'agent', targetId: 'agent-1', averageRating: 4.5, totalReviews: 10, distribution: {} };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getStats(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetAverageRatingQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetAverageRatingQuery;
expect(query.targetType).toBe('agent');
expect(query.targetId).toBe('agent-1');
expect(result).toEqual(expected);
});
});
describe('GET /reviews/me — getMyReviews', () => {
it('dispatches GetReviewsByUserQuery with defaults', async () => {
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getMyReviews(mockUser as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetReviewsByUserQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByUserQuery;
expect(query.userId).toBe('user-1');
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
expect(result).toEqual(expected);
});
});
describe('DELETE /reviews/:id — deleteReview', () => {
it('dispatches DeleteReviewCommand and returns { deleted: true }', async () => {
mockCommandBus.execute.mockResolvedValue(undefined);
const result = await controller.deleteReview('rev-1', mockUser as any);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(DeleteReviewCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteReviewCommand;
expect(cmd.reviewId).toBe('rev-1');
expect(cmd.userId).toBe('user-1');
expect(result).toEqual({ deleted: true });
});
});
});
Nhận Xét về Mẫu Kiểm Thử Controller:
- Tổ chức theo endpoint sử dụng các khối describe
- Kiểm tra việc gửi với đúng kiểu lệnh/truy vấn
- Xác minh từng thuộc tính lệnh sau khi gửi
- Kiểm tra xử lý tham số mặc định
- Kiểm tra xử lý trường tùy chọn (comment null)
- Kiểm tra xác minh kiểu trả về
BẢNG TÓM TẮT
| Tệp | Module | Loại | Các Kiểm Thử Chính |
|---|---|---|---|
| prisma-inquiry.repository.ts | Inquiries | Repository | findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent, toDomain |
| inquiries.controller.ts | Inquiries | Controller | POST create, GET by listing, GET by agent, PATCH mark read, guard enforcement |
| create-inquiry.dto.ts | Inquiries | DTO | listingId, message (max 2000), phone (optional) |
| list-inquiries.dto.ts | Inquiries | DTO | page/limit validation, type transformation |
| prisma-lead.repository.ts | Leads | Repository | CRUD, findByAgent with status filter, getStatsByAgent aggregation |
| lead-score.vo.ts | Leads | Value Object | Range validation (0-100), error messages |
| leads.controller.ts | Leads | Controller | POST create, GET list, GET stats, PATCH status, DELETE, class-level @Roles |
| create-lead.dto.ts | Leads | DTO | name, phone, email, source, score (0-100), notes |
| list-leads.dto.ts | Leads | DTO | status enum validation, pagination |
| update-lead-status.dto.ts | Leads | DTO | status enum validation |
| prisma-review.repository.ts | Reviews | Repository | findById, findByUserAndTarget, CRUD, pagination, getStats with distribution |
| rating.vo.ts | Reviews | Value Object | Integer range (1-5), error messages |
| reviews.controller.ts | Reviews | Controller | POST create, GET by target, GET stats, GET me, DELETE, mixed auth |
| create-review.dto.ts | Reviews | DTO | targetType, targetId, rating (1-5), comment (optional, max 2000) |
| list-reviews.dto.ts | Reviews | DTO | ListReviewsByTargetDto, ReviewStatsDto, pagination |
ƯU TIÊN KIỂM THỬ
Ưu Tiên Cao (Logic Nghiệp Vụ Quan Trọng):
- Các phương thức Repository - tính toàn vẹn dữ liệu, độ chính xác phân trang
- Value Object - xác thực, thông báo lỗi
- Controller - gửi lệnh, ánh xạ tham số
- DTO - quy tắc xác thực, chuyển đổi kiểu dữ liệu
Ưu Tiên Trung Bình (Bảo Vệ An Toàn):
- Xử lý trường tùy chọn
- Ép kiểu null/undefined (→ null)
- Kiểm tra biên phân trang
- Tính toán tổng hợp (trung bình, tổng, phân phối)
Ưu Tiên Thấp (Framework):
- Xác thực decorator (trách nhiệm của framework)
- Tuần tự hóa lỗi
- Tài liệu Swagger