Files
goodgo-platform/docs/audits/TEST_COVERAGE_ANALYSIS_ROOT.md
Ho Ngoc Hai 11f2bf26e6
Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

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:

  1. Repository Hạ Tầng (3 tệp) - Lớp truy cập dữ liệu Prisma
  2. Value Object Miền (2 tệp) - Đối tượng xác thực LeadScore và Rating
  3. DTO Trình Bày (10 tệp) - Các lớp xác thực request/response
  4. 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ấy
  • save() - Tạo bản ghi inquiry mới
  • markAsRead() - Cập nhật cờ isRead
  • findByListing() - 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 listing
  • countUnreadByAgent() - Truy vấn tổng hợp đếm số lượng chưa đọc
  • toDomain() - 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ọn
  • GET /inquiries/listing/:listingId - Danh sách theo listing có phân trang
  • GET /inquiries/agent/me - Danh sách cho agent đã xác thực với guard vai trò AGENT
  • PATCH /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ộc
  • message - 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 1
  • limit - 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 null
  • save() - Tạo lead với điểm tùy chọn
  • update() - Cập nhật trạng thái, điểm, ghi chú
  • delete() - Xóa lead
  • findByAgent() - Phân trang với bộ lọc trạng thái tùy chọn
  • getStatsByAgent() - Tổng hợp theo trạng thái, tính tỷ lệ chuyển đổi và điểm trung bình
  • toDomain() - Á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ọn
  • GET /leads - Danh sách lead của agent với bộ lọc trạng thái tùy chọn
  • GET /leads/stats - Tổng hợp thống kê
  • PATCH /leads/:id/status - Cập nhật trạng thái lead
  • DELETE /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ộc
  • phone - Chuỗi bắt buộc
  • email - Tùy chọn, phải đúng định dạng email
  • source - Chuỗi bắt buộc
  • score - Số tùy chọn, 0-100
  • notes - Đố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, LOST
  • page - Số nguyên tùy chọn, tối thiểu 1
  • limit - 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 null
  • findByUserAndTarget() - Kiểm tra ràng buộc duy nhất
  • save() - Tạo review với bình luận tùy chọn
  • delete() - Xóa review
  • findByTarget() - Danh sách phân trang với kết nối user
  • findByUserId() - Danh sách phân trang theo user
  • getStats() - Tính điểm đánh giá trung bình và phân phối
  • toDomain() - Á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ộc
  • rating - Số nguyên bắt buộc, 1-5
  • comment - 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ộc
  • targetId - Chuỗi bắt buộc
  • page - Tùy chọn, tối thiểu 1
  • limit - Tùy chọn, 1-100

Các Quy Tắc Xác Thực của ReviewStatsDto:

  • targetType - Chuỗi bắt buộc
  • targetId - 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ọn
  • GET /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):

  1. Các phương thức Repository - tính toàn vẹn dữ liệu, độ chính xác phân trang
  2. Value Object - xác thực, thông báo lỗi
  3. Controller - gửi lệnh, ánh xạ tham số
  4. 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):

  1. Xử lý trường tùy chọn
  2. Ép kiểu null/undefined (→ null)
  3. Kiểm tra biên phân trang
  4. Tính toán tổng hợp (trung bình, tổng, phân phối)

Ưu Tiên Thấp (Framework):

  1. Xác thực decorator (trách nhiệm của framework)
  2. Tuần tự hóa lỗi
  3. Tài liệu Swagger