# 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 { const inquiry = await this.prisma.inquiry.findUnique({ where: { id } }); return inquiry ? this.toDomain(inquiry) : null; } async save(entity: InquiryEntity): Promise { 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 { await this.prisma.inquiry.update({ where: { id }, data: { isRead: true }, }); } async findByListing( listingId: string, page: number, limit: number, ): Promise> { 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> { 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 { 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 { 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> { 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> { 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 { const lead = await this.prisma.lead.findUnique({ where: { id } }); return lead ? this.toDomain(lead) : null; } async save(entity: LeadEntity): Promise { 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 { 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 { await this.prisma.lead.delete({ where: { id } }); } async findByAgent( agentId: string, status: string | null, page: number, limit: number, ): Promise> { const take = Math.min(limit, 100); const skip = (page - 1) * take; const where: Record = { 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 { const leads = await this.prisma.lead.findMany({ where: { agentId }, select: { status: true, score: true }, }); const totalLeads = leads.length; const byStatus: Record = {}; 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 { get value(): number { return this.props.value; } static create(value: number): Result { 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 { 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> { 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 { 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; } ``` **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 { const review = await this.prisma.review.findUnique({ where: { id } }); return review ? this.toDomain(review) : null; } async findByUserAndTarget( userId: string, targetType: string, targetId: string, ): Promise { const review = await this.prisma.review.findFirst({ where: { userId, targetType, targetId }, }); return review ? this.toDomain(review) : null; } async save(entity: ReviewEntity): Promise { 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 { await this.prisma.review.delete({ where: { id } }); } async findByTarget( targetType: string, targetId: string, page: number, limit: number, ): Promise> { 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> { 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 { const reviews = await this.prisma.review.findMany({ where: { targetType, targetId }, select: { rating: true }, }); const totalReviews = reviews.length; const distribution: Record = { 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 { get value(): number { return this.props.value; } static create(value: number): Result { 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 { 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> { 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 { 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> { 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 }; let mockEventBus: { publish: ReturnType }; let mockPrisma: { listing: { findUnique: ReturnType }; }; 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 }; let mockEventBus: { publish: ReturnType }; let mockPrisma: { agent: { findUnique: ReturnType }; }; 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 }; let mockQueryBus: { execute: ReturnType }; 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