Files
goodgo-platform/docs/audits/TEST_COVERAGE_ANALYSIS_ROOT.md
Ho Ngoc Hai b8512ebff4 docs: consolidate audit and analysis reports into docs/audits/
Move 36 root-level audit/analysis documents and 7 web app audit documents
into docs/audits/ directory to declutter the project root. Remove stale
EXPLORATION_SUMMARY.txt.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:37:50 +07:00

55 KiB

Test Coverage Analysis Report

GoodGo Platform API — Untested Source Files

Generated: 2026-04-11
Working Directory: /Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/api/


Executive Summary

This report catalogs 17 untested source files across the inquiries, leads, and reviews modules, along with their full implementations. The files fall into four categories:

  1. Infrastructure Repositories (3 files) - Prisma data access layer
  2. Domain Value Objects (2 files) - LeadScore and Rating validation objects
  3. Presentation DTOs (10 files) - Request/response validation classes
  4. Presentation Controllers (2 files) - HTTP endpoint handlers

Two existing test files are provided as reference patterns demonstrating best practices for unit testing handlers and controllers.


PART 1: INQUIRIES MODULE

1.1 Prisma Inquiry Repository

File: 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,
    );
  }
}

Key Methods to Test:

  • findById() - Returns inquiry by ID or null if not found
  • save() - Creates new inquiry record
  • markAsRead() - Updates isRead flag
  • findByListing() - Paginated query by listing, includes relationships
  • findByAgent() - Paginated query through listing agent relationship
  • countUnreadByAgent() - Aggregation query for unread count
  • toDomain() - Private mapper converting Prisma model to domain entity

Test Scenarios:

  • All methods with valid inputs
  • Null returns (no matching records)
  • Pagination edge cases (page bounds, limit clamping)
  • Data mapping accuracy (ISO dates, relationship joins)

1.2 Inquiries Controller

File: 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 };
  }
}

Key Endpoints to Test:

  • POST /inquiries - Create inquiry with phone optional
  • GET /inquiries/listing/:listingId - List by listing with pagination
  • GET /inquiries/agent/me - List for authenticated agent with AGENT role guard
  • PATCH /inquiries/:id/read - Mark as read with role guard

Test Scenarios:

  • Command/query bus dispatch with correct parameters
  • Default pagination values (page: 1, limit: 20)
  • Null phone handling (converts to null when not provided)
  • Guard/decorator enforcement (JwtAuthGuard, RolesGuard, @Roles('AGENT'))

1.3 Create Inquiry DTO

File: 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;
}

Validations:

  • listingId - Required string
  • message - Required string, max 2000 characters
  • phone - Optional string

Test Scenarios:

  • Valid input with all fields
  • Valid input without phone
  • Missing required fields
  • Message exceeding 2000 characters

1.4 List Inquiries DTO

File: 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;
}

Validations:

  • page - Optional integer, minimum 1
  • limit - Optional integer, min 1, max 100

Test Scenarios:

  • Valid pagination (page: 1, limit: 20)
  • Custom pagination
  • Invalid: page < 1, limit < 1, limit > 100
  • Type transformation (string to number)

PART 2: LEADS MODULE

2.1 Prisma Lead Repository

File: 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,
    );
  }
}

Key Methods to Test:

  • findById() - Returns lead or null
  • save() - Creates lead with optional score
  • update() - Updates status, score, notes
  • delete() - Deletes lead
  • findByAgent() - Paginated with optional status filter
  • getStatsByAgent() - Aggregates by status, calculates conversion rate and avg score
  • toDomain() - Maps Prisma model including LeadScore VO instantiation

Test Scenarios:

  • CRUD operations
  • Optional score handling (null and valid values)
  • Status filtering in paginated query
  • Stats calculations (zero leads, no scores, conversion rate precision)
  • Pagination edge cases

2.2 Lead Score Value Object

File: 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 }));
  }
}

Validation Rules:

  • Value must be integer between 0 and 100 inclusive

Test Scenarios:

  • Valid scores (0, 50, 100)
  • Invalid: negative, > 100, null, string
  • Error message in Vietnamese
  • Value object equality (should be comparable)

2.3 Leads Controller

File: 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 };
  }
}

Key Endpoints to Test:

  • POST /leads - Create lead with optional email, score, notes
  • GET /leads - List agent's leads with optional status filter
  • GET /leads/stats - Stats aggregation
  • PATCH /leads/:id/status - Update lead status
  • DELETE /leads/:id - Delete lead

Test Scenarios:

  • Class-level guard (@Roles('AGENT') applies to all methods)
  • Optional field handling (email, score, notes → null)
  • Status filtering (null passes through, specific status filters)
  • Command/query parameter mapping
  • Return types verify (updated: true, deleted: true)

2.4 Create Lead DTO

File: 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>;
}

Validations:

  • name - Required string
  • phone - Required string
  • email - Optional, must be valid email format
  • source - Required string
  • score - Optional number, 0-100
  • notes - Optional object

Test Scenarios:

  • All required fields present
  • Optional fields omitted
  • Invalid email format
  • Score out of range
  • Invalid note type

2.5 List Leads DTO

File: 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;
}

Validations:

  • status - Optional, must be one of: NEW, CONTACTED, QUALIFIED, NEGOTIATING, CONVERTED, LOST
  • page - Optional integer, minimum 1
  • limit - Optional integer, 1-100

Test Scenarios:

  • Valid pagination
  • Valid status values
  • Invalid status
  • Type transformation (string to number)
  • Boundary checks

2.6 Update Lead Status DTO

File: 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;
}

Validations:

  • status - Required, must be valid lead status

Test Scenarios:

  • Valid status transitions
  • Invalid status
  • Missing status field

PART 3: REVIEWS MODULE

3.1 Prisma Review Repository

File: 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,
    );
  }
}

Key Methods to Test:

  • findById() - Returns review or null
  • findByUserAndTarget() - Unique constraint check
  • save() - Creates review with optional comment
  • delete() - Deletes review
  • findByTarget() - Paginated list with user join
  • findByUserId() - Paginated list by user
  • getStats() - Calculates average rating and distribution
  • toDomain() - Maps Prisma model including Rating VO

Test Scenarios:

  • All CRUD operations
  • Optional comment handling
  • Pagination with relationships (user fullName join)
  • Stats with distribution calculation (1-5 scale)
  • Zero reviews edge case
  • Data accuracy (ISO dates, decimal rounding)

3.2 Rating Value Object

File: 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 }));
  }
}

Validation Rules:

  • Must be integer between 1 and 5 inclusive

Test Scenarios:

  • Valid ratings (1-5)
  • Invalid: 0, 6, -1, null, float (2.5), non-integer
  • Error message verification
  • Value object creation and equality

3.3 Create Review DTO

File: 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;
}

Validations:

  • targetType - Required string (e.g. "agent", "property")
  • targetId - Required string
  • rating - Required integer, 1-5
  • comment - Optional string, max 2000 characters

Test Scenarios:

  • Valid review with all fields
  • Valid without comment
  • Missing required fields
  • Invalid rating (0, 6, 2.5)
  • Comment exceeding max length

3.4 List Reviews DTOs

File: 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;
}

ListReviewsByTargetDto Validations:

  • targetType - Required string
  • targetId - Required string
  • page - Optional, min 1
  • limit - Optional, 1-100

ReviewStatsDto Validations:

  • targetType - Required string
  • targetId - Required string

Test Scenarios:

  • Valid pagination with required target fields
  • Type transformation
  • Boundary checks on pagination

3.5 Reviews Controller

File: 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 };
  }
}

Key Endpoints to Test:

  • POST /reviews - Create with optional comment
  • GET /reviews - List by target (no auth required)
  • GET /reviews/stats - Stats for target (no auth required)
  • GET /reviews/me - List user's reviews (requires auth)
  • DELETE /reviews/:id - Delete review (requires auth)

Test Scenarios:

  • Command/query dispatch with correct parameters
  • Default pagination (1, 20)
  • Null comment handling
  • Auth requirements (JWT for POST, GET /me, DELETE; no auth for GET, GET/stats)
  • Return types (deleted: true)

PART 4: REFERENCE TEST PATTERNS

4.1 Example Handler Test: Create Inquiry Handler

File: 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',
      }),
    );
  });
});

Pattern Insights:

  • Uses vi.fn() from Vitest for mocking
  • Mock all repository methods in beforeEach
  • Test happy path with all dependencies mocked
  • Test error scenarios (NotFoundException)
  • Verify side effects (event publishing)
  • Uses exact parameter checking or matchers (expect.objectContaining)

4.2 Example Handler Test: Create Lead Handler

File: 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();
  });
});

Additional Pattern Notes:

  • Tests optional parameter handling (score: null)
  • Tests validation errors from value objects (LeadScore)
  • Uses same mock setup structure as inquiry handler

4.3 Example Controller Test: Reviews Controller

File: 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 });
    });
  });
});

Controller Test Pattern Insights:

  • Organized by endpoint using describe blocks
  • Tests dispatch with correct command/query types
  • Verifies individual command properties after dispatch
  • Tests default parameter handling
  • Tests optional field handling (comment null)
  • Tests return type verification

SUMMARY TABLE

File Module Type Key Tests
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

TESTING PRIORITIES

High Priority (Critical Business Logic):

  1. Repository methods - data integrity, pagination accuracy
  2. Value objects - validation, error messages
  3. Controllers - command dispatch, parameter mapping
  4. DTOs - validation rules, type transformation

Medium Priority (Guard Rails):

  1. Optional field handling
  2. Null/undefined coercion (→ null)
  3. Pagination boundary checks
  4. Aggregation calculations (avg, sum, distribution)

Low Priority (Framework):

  1. Decorator validation (framework responsibility)
  2. Error serialization
  3. Swagger documentation