# 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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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`

```typescript
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

