Move 36 root-level audit/analysis documents and 7 web app audit documents into docs/audits/ directory to declutter the project root. Remove stale EXPLORATION_SUMMARY.txt. Co-Authored-By: Paperclip <noreply@paperclip.ing>
55 KiB
Test Coverage Analysis Report
GoodGo Platform API — Untested Source Files
Generated: 2026-04-11
Working Directory: /Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/api/
Executive Summary
This report catalogs 17 untested source files across the inquiries, leads, and reviews modules, along with their full implementations. The files fall into four categories:
- Infrastructure Repositories (3 files) - Prisma data access layer
- Domain Value Objects (2 files) - LeadScore and Rating validation objects
- Presentation DTOs (10 files) - Request/response validation classes
- Presentation Controllers (2 files) - HTTP endpoint handlers
Two existing test files are provided as reference patterns demonstrating best practices for unit testing handlers and controllers.
PART 1: INQUIRIES MODULE
1.1 Prisma Inquiry Repository
File: src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts
import { Injectable } from '@nestjs/common';
import { type Inquiry as PrismaInquiry } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import { type IInquiryRepository, type PaginatedResult } from '../../domain/repositories/inquiry.repository';
@Injectable()
export class PrismaInquiryRepository implements IInquiryRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<InquiryEntity | null> {
const inquiry = await this.prisma.inquiry.findUnique({ where: { id } });
return inquiry ? this.toDomain(inquiry) : null;
}
async save(entity: InquiryEntity): Promise<void> {
await this.prisma.inquiry.create({
data: {
id: entity.id,
listingId: entity.listingId,
userId: entity.userId,
message: entity.message,
phone: entity.phone,
isRead: entity.isRead,
},
});
}
async markAsRead(id: string): Promise<void> {
await this.prisma.inquiry.update({
where: { id },
data: { isRead: true },
});
}
async findByListing(
listingId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listingId };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByAgent(
agentId: string,
page: number,
limit: number,
): Promise<PaginatedResult<InquiryReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { listing: { agentId } };
const [data, total] = await Promise.all([
this.prisma.inquiry.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: {
listing: { select: { id: true, property: { select: { title: true } } } },
user: { select: { id: true, fullName: true, phone: true } },
},
}),
this.prisma.inquiry.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
listingId: r.listingId,
listingTitle: r.listing.property.title,
userId: r.userId,
userName: r.user.fullName,
userPhone: r.user.phone,
message: r.message,
phone: r.phone,
isRead: r.isRead,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async countUnreadByAgent(agentId: string): Promise<number> {
return this.prisma.inquiry.count({
where: {
isRead: false,
listing: { agentId },
},
});
}
private toDomain(raw: PrismaInquiry): InquiryEntity {
return new InquiryEntity(
raw.id,
{
listingId: raw.listingId,
userId: raw.userId,
message: raw.message,
phone: raw.phone,
isRead: raw.isRead,
},
raw.createdAt,
);
}
}
Key Methods to Test:
findById()- Returns inquiry by ID or null if not foundsave()- Creates new inquiry recordmarkAsRead()- Updates isRead flagfindByListing()- Paginated query by listing, includes relationshipsfindByAgent()- Paginated query through listing agent relationshipcountUnreadByAgent()- Aggregation query for unread counttoDomain()- Private mapper converting Prisma model to domain entity
Test Scenarios:
- All methods with valid inputs
- Null returns (no matching records)
- Pagination edge cases (page bounds, limit clamping)
- Data mapping accuracy (ISO dates, relationship joins)
1.2 Inquiries Controller
File: src/modules/inquiries/presentation/controllers/inquiries.controller.ts
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
@ApiTags('inquiries')
@Controller('inquiries')
export class InquiriesController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create an inquiry for a listing' })
@ApiResponse({ status: 201, description: 'Inquiry created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 404, description: 'Listing not found' })
@UseGuards(JwtAuthGuard)
@Post()
async createInquiry(
@Body() dto: CreateInquiryDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateInquiryResult> {
return this.commandBus.execute(
new CreateInquiryCommand(
user.sub,
dto.listingId,
dto.message,
dto.phone ?? null,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries by listing' })
@ApiParam({ name: 'listingId', description: 'Listing ID' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('listing/:listingId')
async getByListing(
@Param('listingId') listingId: string,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByListingQuery(
listingId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'List inquiries for current agent' })
@ApiResponse({ status: 200, description: 'Paginated list of inquiries for agent' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not an agent' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Get('agent/me')
async getMyInquiries(
@CurrentUser() user: JwtPayload,
@Query() dto: ListInquiriesDto,
): Promise<PaginatedResult<InquiryReadDto>> {
return this.queryBus.execute(
new GetInquiriesByAgentQuery(
user.sub,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Mark inquiry as read' })
@ApiParam({ name: 'id', description: 'Inquiry ID' })
@ApiResponse({ status: 200, description: 'Inquiry marked as read' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Forbidden — not the listing agent' })
@ApiResponse({ status: 404, description: 'Inquiry not found' })
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
@Patch(':id/read')
async markAsRead(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ success: boolean }> {
await this.commandBus.execute(
new MarkInquiryReadCommand(id, user.sub),
);
return { success: true };
}
}
Key Endpoints to Test:
POST /inquiries- Create inquiry with phone optionalGET /inquiries/listing/:listingId- List by listing with paginationGET /inquiries/agent/me- List for authenticated agent with AGENT role guardPATCH /inquiries/:id/read- Mark as read with role guard
Test Scenarios:
- Command/query bus dispatch with correct parameters
- Default pagination values (page: 1, limit: 20)
- Null phone handling (converts to null when not provided)
- Guard/decorator enforcement (JwtAuthGuard, RolesGuard, @Roles('AGENT'))
1.3 Create Inquiry DTO
File: src/modules/inquiries/presentation/dto/create-inquiry.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator';
export class CreateInquiryDto {
@ApiProperty({ description: 'ID of the listing' })
@IsString()
@IsNotEmpty()
listingId!: string;
@ApiProperty({ description: 'Tin nhắn yêu cầu tư vấn' })
@IsString()
@IsNotEmpty()
@MaxLength(2000)
message!: string;
@ApiPropertyOptional({ description: 'Số điện thoại liên hệ' })
@IsOptional()
@IsString()
phone?: string;
}
Validations:
listingId- Required stringmessage- Required string, max 2000 charactersphone- Optional string
Test Scenarios:
- Valid input with all fields
- Valid input without phone
- Missing required fields
- Message exceeding 2000 characters
1.4 List Inquiries DTO
File: src/modules/inquiries/presentation/dto/list-inquiries.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsOptional, Max, Min } from 'class-validator';
export class ListInquiriesDto {
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
Validations:
page- Optional integer, minimum 1limit- Optional integer, min 1, max 100
Test Scenarios:
- Valid pagination (page: 1, limit: 20)
- Custom pagination
- Invalid: page < 1, limit < 1, limit > 100
- Type transformation (string to number)
PART 2: LEADS MODULE
2.1 Prisma Lead Repository
File: src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts
import { Injectable } from '@nestjs/common';
import { type Lead as PrismaLead } from '@prisma/client';
import { type PrismaService } from '@modules/shared';
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
@Injectable()
export class PrismaLeadRepository implements ILeadRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<LeadEntity | null> {
const lead = await this.prisma.lead.findUnique({ where: { id } });
return lead ? this.toDomain(lead) : null;
}
async save(entity: LeadEntity): Promise<void> {
await this.prisma.lead.create({
data: {
id: entity.id,
agentId: entity.agentId,
name: entity.name,
phone: entity.phone,
email: entity.email,
source: entity.source,
score: entity.score?.value ?? null,
notes: entity.notes as never,
status: entity.status,
},
});
}
async update(entity: LeadEntity): Promise<void> {
await this.prisma.lead.update({
where: { id: entity.id },
data: {
status: entity.status,
score: entity.score?.value ?? null,
notes: entity.notes as never,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.lead.delete({ where: { id } });
}
async findByAgent(
agentId: string,
status: string | null,
page: number,
limit: number,
): Promise<PaginatedResult<LeadReadDto>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where: Record<string, unknown> = { agentId };
if (status) {
where['status'] = status;
}
const [data, total] = await Promise.all([
this.prisma.lead.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
}),
this.prisma.lead.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
agentId: r.agentId,
name: r.name,
phone: r.phone,
email: r.email,
source: r.source,
score: r.score,
notes: r.notes,
status: r.status,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async getStatsByAgent(agentId: string): Promise<LeadStatsData> {
const leads = await this.prisma.lead.findMany({
where: { agentId },
select: { status: true, score: true },
});
const totalLeads = leads.length;
const byStatus: Record<string, number> = {};
let scoreSum = 0;
let scoreCount = 0;
let convertedCount = 0;
for (const lead of leads) {
byStatus[lead.status] = (byStatus[lead.status] ?? 0) + 1;
if (lead.score !== null) {
scoreSum += lead.score;
scoreCount++;
}
if (lead.status === 'CONVERTED') {
convertedCount++;
}
}
return {
totalLeads,
byStatus,
conversionRate: totalLeads > 0
? Math.round((convertedCount / totalLeads) * 10000) / 100
: 0,
avgScore: scoreCount > 0
? Math.round((scoreSum / scoreCount) * 10) / 10
: null,
};
}
private toDomain(raw: PrismaLead): LeadEntity {
let score: LeadScore | null = null;
if (raw.score !== null) {
score = LeadScore.create(raw.score).unwrap();
}
return new LeadEntity(
raw.id,
{
agentId: raw.agentId,
name: raw.name,
phone: raw.phone,
email: raw.email,
source: raw.source,
score,
notes: raw.notes,
status: raw.status as LeadStatus,
},
raw.createdAt,
raw.updatedAt,
);
}
}
Key Methods to Test:
findById()- Returns lead or nullsave()- Creates lead with optional scoreupdate()- Updates status, score, notesdelete()- Deletes leadfindByAgent()- Paginated with optional status filtergetStatsByAgent()- Aggregates by status, calculates conversion rate and avg scoretoDomain()- Maps Prisma model including LeadScore VO instantiation
Test Scenarios:
- CRUD operations
- Optional score handling (null and valid values)
- Status filtering in paginated query
- Stats calculations (zero leads, no scores, conversion rate precision)
- Pagination edge cases
2.2 Lead Score Value Object
File: src/modules/leads/domain/value-objects/lead-score.vo.ts
import { Result, ValueObject } from '@modules/shared';
interface LeadScoreProps {
value: number;
}
export class LeadScore extends ValueObject<LeadScoreProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<LeadScore, string> {
if (value < 0 || value > 100) {
return Result.err('Điểm lead phải từ 0 đến 100');
}
return Result.ok(new LeadScore({ value }));
}
}
Validation Rules:
- Value must be integer between 0 and 100 inclusive
Test Scenarios:
- Valid scores (0, 50, 100)
- Invalid: negative, > 100, null, string
- Error message in Vietnamese
- Value object equality (should be comparable)
2.3 Leads Controller
File: src/modules/leads/presentation/controllers/leads.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command';
import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
import { type CreateLeadDto } from '../dto/create-lead.dto';
import { type ListLeadsDto } from '../dto/list-leads.dto';
import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
@ApiTags('leads')
@ApiBearerAuth('JWT')
@Controller('leads')
@UseGuards(JwtAuthGuard, RolesGuard)
@Roles('AGENT')
export class LeadsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiOperation({ summary: 'Tạo lead mới' })
@ApiResponse({ status: 201, description: 'Lead đã được tạo thành công' })
@ApiResponse({ status: 400, description: 'Lỗi validation' })
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@Post()
async createLead(
@Body() dto: CreateLeadDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateLeadResult> {
return this.commandBus.execute(
new CreateLeadCommand(
user.sub,
dto.name,
dto.phone,
dto.email ?? null,
dto.source,
dto.score ?? null,
dto.notes ?? null,
),
);
}
@ApiOperation({ summary: 'Danh sách lead của agent' })
@ApiResponse({ status: 200, description: 'Danh sách lead phân trang' })
@Get()
async getLeads(
@Query() dto: ListLeadsDto,
@CurrentUser() user: JwtPayload,
): Promise<PaginatedResult<LeadReadDto>> {
return this.queryBus.execute(
new GetLeadsByAgentQuery(
user.sub,
dto.status ?? null,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Thống kê lead của agent' })
@ApiResponse({ status: 200, description: 'Thống kê lead' })
@Get('stats')
async getStats(
@CurrentUser() user: JwtPayload,
): Promise<LeadStatsData> {
return this.queryBus.execute(
new GetLeadStatsQuery(user.sub),
);
}
@ApiOperation({ summary: 'Cập nhật trạng thái lead' })
@ApiParam({ name: 'id', description: 'Lead ID' })
@ApiResponse({ status: 200, description: 'Trạng thái đã được cập nhật' })
@ApiResponse({ status: 400, description: 'Chuyển trạng thái không hợp lệ' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
@Patch(':id/status')
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateLeadStatusDto,
@CurrentUser() user: JwtPayload,
): Promise<{ updated: boolean }> {
await this.commandBus.execute(
new UpdateLeadStatusCommand(id, user.sub, dto.status),
);
return { updated: true };
}
@ApiOperation({ summary: 'Xóa lead' })
@ApiParam({ name: 'id', description: 'Lead ID' })
@ApiResponse({ status: 200, description: 'Lead đã được xóa' })
@ApiResponse({ status: 403, description: 'Không có quyền' })
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
@Delete(':id')
async deleteLead(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
await this.commandBus.execute(new DeleteLeadCommand(id, user.sub));
return { deleted: true };
}
}
Key Endpoints to Test:
POST /leads- Create lead with optional email, score, notesGET /leads- List agent's leads with optional status filterGET /leads/stats- Stats aggregationPATCH /leads/:id/status- Update lead statusDELETE /leads/:id- Delete lead
Test Scenarios:
- Class-level guard (@Roles('AGENT') applies to all methods)
- Optional field handling (email, score, notes → null)
- Status filtering (null passes through, specific status filters)
- Command/query parameter mapping
- Return types verify (updated: true, deleted: true)
2.4 Create Lead DTO
File: src/modules/leads/presentation/dto/create-lead.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
export class CreateLeadDto {
@ApiProperty({ example: 'Nguyễn Văn A', description: 'Tên khách hàng tiềm năng' })
@IsString()
@IsNotEmpty()
name!: string;
@ApiProperty({ example: '0901234567', description: 'Số điện thoại' })
@IsString()
@IsNotEmpty()
phone!: string;
@ApiPropertyOptional({ example: 'nguyen@example.com', description: 'Email' })
@IsOptional()
@IsEmail()
email?: string;
@ApiProperty({ example: 'website', description: 'Nguồn lead' })
@IsString()
@IsNotEmpty()
source!: string;
@ApiPropertyOptional({ example: 75, description: 'Điểm lead (0-100)' })
@IsOptional()
@IsNumber()
@Min(0)
@Max(100)
score?: number;
@ApiPropertyOptional({ description: 'Ghi chú bổ sung' })
@IsOptional()
notes?: Record<string, unknown>;
}
Validations:
name- Required stringphone- Required stringemail- Optional, must be valid email formatsource- Required stringscore- Optional number, 0-100notes- Optional object
Test Scenarios:
- All required fields present
- Optional fields omitted
- Invalid email format
- Score out of range
- Invalid note type
2.5 List Leads DTO
File: src/modules/leads/presentation/dto/list-leads.dto.ts
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsIn, IsInt, IsOptional, Max, Min } from 'class-validator';
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
export class ListLeadsDto {
@ApiPropertyOptional({
enum: LEAD_STATUSES,
description: 'Lọc theo trạng thái',
})
@IsOptional()
@IsIn(LEAD_STATUSES)
status?: string;
@ApiPropertyOptional({ example: 1, description: 'Trang', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Số lượng mỗi trang', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
Validations:
status- Optional, must be one of: NEW, CONTACTED, QUALIFIED, NEGOTIATING, CONVERTED, LOSTpage- Optional integer, minimum 1limit- Optional integer, 1-100
Test Scenarios:
- Valid pagination
- Valid status values
- Invalid status
- Type transformation (string to number)
- Boundary checks
2.6 Update Lead Status DTO
File: src/modules/leads/presentation/dto/update-lead-status.dto.ts
import { ApiProperty } from '@nestjs/swagger';
import { IsIn } from 'class-validator';
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'] as const;
export class UpdateLeadStatusDto {
@ApiProperty({
enum: LEAD_STATUSES,
description: 'Trạng thái mới của lead',
example: 'CONTACTED',
})
@IsIn(LEAD_STATUSES)
status!: string;
}
Validations:
status- Required, must be valid lead status
Test Scenarios:
- Valid status transitions
- Invalid status
- Missing status field
PART 3: REVIEWS MODULE
3.1 Prisma Review Repository
File: src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts
import { Injectable } from '@nestjs/common';
import { type Review as PrismaReview } from '@prisma/client';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { PrismaService } from '@modules/shared';
import { ReviewEntity } from '../../domain/entities/review.entity';
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
import { type IReviewRepository, type PaginatedResult } from '../../domain/repositories/review.repository';
import { Rating } from '../../domain/value-objects/rating.vo';
@Injectable()
export class PrismaReviewRepository implements IReviewRepository {
constructor(private readonly prisma: PrismaService) {}
async findById(id: string): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findUnique({ where: { id } });
return review ? this.toDomain(review) : null;
}
async findByUserAndTarget(
userId: string,
targetType: string,
targetId: string,
): Promise<ReviewEntity | null> {
const review = await this.prisma.review.findFirst({
where: { userId, targetType, targetId },
});
return review ? this.toDomain(review) : null;
}
async save(entity: ReviewEntity): Promise<void> {
await this.prisma.review.create({
data: {
id: entity.id,
userId: entity.userId,
targetType: entity.targetType,
targetId: entity.targetId,
rating: entity.rating.value,
comment: entity.comment,
},
});
}
async delete(id: string): Promise<void> {
await this.prisma.review.delete({ where: { id } });
}
async findByTarget(
targetType: string,
targetId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { targetType, targetId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async findByUserId(
userId: string,
page: number,
limit: number,
): Promise<PaginatedResult<ReviewItemData>> {
const take = Math.min(limit, 100);
const skip = (page - 1) * take;
const where = { userId };
const [data, total] = await Promise.all([
this.prisma.review.findMany({
where,
skip,
take,
orderBy: { createdAt: 'desc' },
include: { user: { select: { id: true, fullName: true } } },
}),
this.prisma.review.count({ where }),
]);
return {
data: data.map((r) => ({
id: r.id,
userId: r.userId,
userName: r.user.fullName,
targetType: r.targetType,
targetId: r.targetId,
rating: r.rating,
comment: r.comment,
createdAt: r.createdAt.toISOString(),
})),
total,
page,
limit: take,
totalPages: Math.ceil(total / take),
};
}
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
const reviews = await this.prisma.review.findMany({
where: { targetType, targetId },
select: { rating: true },
});
const totalReviews = reviews.length;
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
let sum = 0;
for (const r of reviews) {
sum += r.rating;
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
}
return {
targetType,
targetId,
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
totalReviews,
distribution,
};
}
private toDomain(raw: PrismaReview): ReviewEntity {
const rating = Rating.create(raw.rating).unwrap();
return new ReviewEntity(
raw.id,
{
userId: raw.userId,
targetType: raw.targetType,
targetId: raw.targetId,
rating,
comment: raw.comment,
},
raw.createdAt,
);
}
}
Key Methods to Test:
findById()- Returns review or nullfindByUserAndTarget()- Unique constraint checksave()- Creates review with optional commentdelete()- Deletes reviewfindByTarget()- Paginated list with user joinfindByUserId()- Paginated list by usergetStats()- Calculates average rating and distributiontoDomain()- Maps Prisma model including Rating VO
Test Scenarios:
- All CRUD operations
- Optional comment handling
- Pagination with relationships (user fullName join)
- Stats with distribution calculation (1-5 scale)
- Zero reviews edge case
- Data accuracy (ISO dates, decimal rounding)
3.2 Rating Value Object
File: src/modules/reviews/domain/value-objects/rating.vo.ts
import { Result, ValueObject } from '@modules/shared';
interface RatingProps {
value: number;
}
export class Rating extends ValueObject<RatingProps> {
get value(): number { return this.props.value; }
static create(value: number): Result<Rating, string> {
if (!Number.isInteger(value) || value < 1 || value > 5) {
return Result.err('Đánh giá phải từ 1 đến 5 sao');
}
return Result.ok(new Rating({ value }));
}
}
Validation Rules:
- Must be integer between 1 and 5 inclusive
Test Scenarios:
- Valid ratings (1-5)
- Invalid: 0, 6, -1, null, float (2.5), non-integer
- Error message verification
- Value object creation and equality
3.3 Create Review DTO
File: src/modules/reviews/presentation/dto/create-review.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, MaxLength, Min } from 'class-validator';
export class CreateReviewDto {
@ApiProperty({ example: 'agent', description: 'Target entity type (e.g. agent, property)' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiProperty({ example: 5, description: 'Rating from 1 to 5', minimum: 1, maximum: 5 })
@IsInt()
@Min(1)
@Max(5)
rating!: number;
@ApiPropertyOptional({ example: 'Dịch vụ rất tốt!', description: 'Optional review comment' })
@IsOptional()
@IsString()
@MaxLength(2000)
comment?: string;
}
Validations:
targetType- Required string (e.g. "agent", "property")targetId- Required stringrating- Required integer, 1-5comment- Optional string, max 2000 characters
Test Scenarios:
- Valid review with all fields
- Valid without comment
- Missing required fields
- Invalid rating (0, 6, 2.5)
- Comment exceeding max length
3.4 List Reviews DTOs
File: src/modules/reviews/presentation/dto/list-reviews.dto.ts
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator';
export class ListReviewsByTargetDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
@ApiPropertyOptional({ example: 1, description: 'Page number', default: 1 })
@IsOptional()
@IsInt()
@Min(1)
@Type(() => Number)
page?: number;
@ApiPropertyOptional({ example: 20, description: 'Items per page', default: 20 })
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Type(() => Number)
limit?: number;
}
export class ReviewStatsDto {
@ApiProperty({ example: 'agent', description: 'Target entity type' })
@IsString()
@IsNotEmpty()
targetType!: string;
@ApiProperty({ example: 'clxyz123', description: 'Target entity ID' })
@IsString()
@IsNotEmpty()
targetId!: string;
}
ListReviewsByTargetDto Validations:
targetType- Required stringtargetId- Required stringpage- Optional, min 1limit- Optional, 1-100
ReviewStatsDto Validations:
targetType- Required stringtargetId- Required string
Test Scenarios:
- Valid pagination with required target fields
- Type transformation
- Boundary checks on pagination
3.5 Reviews Controller
File: src/modules/reviews/presentation/controllers/reviews.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Post,
Query,
UseGuards,
} from '@nestjs/common';
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
import { CommandBus, QueryBus } from '@nestjs/cqrs';
import {
ApiTags,
ApiOperation,
ApiResponse,
ApiBearerAuth,
ApiParam,
} from '@nestjs/swagger';
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
import { type PaginatedResult } from '../../domain/repositories/review.repository';
import { type CreateReviewDto } from '../dto/create-review.dto';
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
@ApiTags('reviews')
@Controller('reviews')
export class ReviewsController {
constructor(
private readonly commandBus: CommandBus,
private readonly queryBus: QueryBus,
) {}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Create a review' })
@ApiResponse({ status: 201, description: 'Review created successfully' })
@ApiResponse({ status: 400, description: 'Validation error' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
@UseGuards(JwtAuthGuard)
@Post()
async createReview(
@Body() dto: CreateReviewDto,
@CurrentUser() user: JwtPayload,
): Promise<CreateReviewResult> {
return this.commandBus.execute(
new CreateReviewCommand(
user.sub,
dto.targetType,
dto.targetId,
dto.rating,
dto.comment ?? null,
),
);
}
@ApiOperation({ summary: 'List reviews by target' })
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
@Get()
async getReviewsByTarget(
@Query() dto: ListReviewsByTargetDto,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByTargetQuery(
dto.targetType,
dto.targetId,
dto.page ?? 1,
dto.limit ?? 20,
),
);
}
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
@ApiResponse({ status: 200, description: 'Rating statistics' })
@Get('stats')
async getStats(
@Query() dto: ReviewStatsDto,
): Promise<ReviewStatsData> {
return this.queryBus.execute(
new GetAverageRatingQuery(dto.targetType, dto.targetId),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Get reviews by authenticated user' })
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@UseGuards(JwtAuthGuard)
@Get('me')
async getMyReviews(
@CurrentUser() user: JwtPayload,
@Query('page') page?: number,
@Query('limit') limit?: number,
): Promise<PaginatedResult<ReviewItemData>> {
return this.queryBus.execute(
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
);
}
@ApiBearerAuth('JWT')
@ApiOperation({ summary: 'Delete own review' })
@ApiParam({ name: 'id', description: 'Review ID' })
@ApiResponse({ status: 200, description: 'Review deleted' })
@ApiResponse({ status: 401, description: 'Unauthorized' })
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
@ApiResponse({ status: 404, description: 'Review not found' })
@UseGuards(JwtAuthGuard)
@Delete(':id')
async deleteReview(
@Param('id') id: string,
@CurrentUser() user: JwtPayload,
): Promise<{ deleted: boolean }> {
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
return { deleted: true };
}
}
Key Endpoints to Test:
POST /reviews- Create with optional commentGET /reviews- List by target (no auth required)GET /reviews/stats- Stats for target (no auth required)GET /reviews/me- List user's reviews (requires auth)DELETE /reviews/:id- Delete review (requires auth)
Test Scenarios:
- Command/query dispatch with correct parameters
- Default pagination (1, 20)
- Null comment handling
- Auth requirements (JWT for POST, GET /me, DELETE; no auth for GET, GET/stats)
- Return types (deleted: true)
PART 4: REFERENCE TEST PATTERNS
4.1 Example Handler Test: Create Inquiry Handler
File: src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts
import type { EventBus } from '@nestjs/cqrs';
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
describe('CreateInquiryHandler', () => {
let handler: CreateInquiryHandler;
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
listing: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockInquiryRepo = {
findById: vi.fn(),
save: vi.fn(),
markAsRead: vi.fn(),
findByListing: vi.fn(),
findByAgent: vi.fn(),
countUnreadByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
listing: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateInquiryHandler(
mockInquiryRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});
it('creates an inquiry successfully', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Tôi muốn xem nhà',
'0901234567',
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.listingId).toBe('listing-1');
expect(result.createdAt).toBeDefined();
expect(mockInquiryRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('throws NotFoundException when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const command = new CreateInquiryCommand(
'user-1',
'listing-not-exist',
'Tôi muốn xem nhà',
null,
);
await expect(handler.execute(command)).rejects.toThrow(
"Listing with id 'listing-not-exist' not found",
);
expect(mockInquiryRepo.save).not.toHaveBeenCalled();
});
it('publishes domain events after saving', async () => {
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
mockInquiryRepo.save.mockResolvedValue(undefined);
const command = new CreateInquiryCommand(
'user-1',
'listing-1',
'Cho tôi hỏi giá',
null,
);
await handler.execute(command);
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalledWith(
expect.objectContaining({
eventName: 'inquiry.created',
listingId: 'listing-1',
userId: 'user-1',
}),
);
});
});
Pattern Insights:
- Uses
vi.fn()from Vitest for mocking - Mock all repository methods in beforeEach
- Test happy path with all dependencies mocked
- Test error scenarios (NotFoundException)
- Verify side effects (event publishing)
- Uses exact parameter checking or matchers (
expect.objectContaining)
4.2 Example Handler Test: Create Lead Handler
File: src/modules/leads/application/__tests__/create-lead.handler.spec.ts
import type { EventBus } from '@nestjs/cqrs';
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
import { CreateLeadHandler } from '../commands/create-lead/create-lead.handler';
describe('CreateLeadHandler', () => {
let handler: CreateLeadHandler;
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
let mockPrisma: {
agent: { findUnique: ReturnType<typeof vi.fn> };
};
beforeEach(() => {
mockLeadRepo = {
findById: vi.fn(),
save: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
findByAgent: vi.fn(),
getStatsByAgent: vi.fn(),
};
mockEventBus = { publish: vi.fn() };
mockPrisma = {
agent: { findUnique: vi.fn() },
};
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
handler = new CreateLeadHandler(
mockLeadRepo as any,
mockEventBus as unknown as EventBus,
mockPrisma as any,
mockLogger as any,
);
});
it('creates a lead successfully', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockLeadRepo.save.mockResolvedValue(undefined);
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn A',
'0901234567',
'a@example.com',
'WEBSITE',
75,
{ note: 'Interested in District 7' },
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.status).toBe('NEW');
expect(result.createdAt).toBeDefined();
expect(mockLeadRepo.save).toHaveBeenCalledTimes(1);
expect(mockEventBus.publish).toHaveBeenCalled();
});
it('creates a lead with null score', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
mockLeadRepo.save.mockResolvedValue(undefined);
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn B',
'0907654321',
null,
'REFERRAL',
null,
null,
);
const result = await handler.execute(command);
expect(result.id).toBeDefined();
expect(result.status).toBe('NEW');
});
it('throws NotFoundException when agent not found', async () => {
mockPrisma.agent.findUnique.mockResolvedValue(null);
const command = new CreateLeadCommand(
'not-an-agent',
'Nguyễn Văn A',
'0901234567',
null,
'WEBSITE',
null,
null,
);
await expect(handler.execute(command)).rejects.toThrow(
"Agent with id 'not-an-agent' not found",
);
expect(mockLeadRepo.save).not.toHaveBeenCalled();
});
it('throws ValidationException for invalid score', async () => {
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
const command = new CreateLeadCommand(
'user-1',
'Nguyễn Văn A',
'0901234567',
null,
'WEBSITE',
150,
null,
);
await expect(handler.execute(command)).rejects.toThrow(
'Điểm lead phải từ 0 đến 100',
);
expect(mockLeadRepo.save).not.toHaveBeenCalled();
});
});
Additional Pattern Notes:
- Tests optional parameter handling (score: null)
- Tests validation errors from value objects (LeadScore)
- Uses same mock setup structure as inquiry handler
4.3 Example Controller Test: Reviews Controller
File: src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
import { ReviewsController } from '../controllers/reviews.controller';
describe('ReviewsController', () => {
let controller: ReviewsController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
mockQueryBus = { execute: vi.fn() };
controller = new ReviewsController(mockCommandBus as any, mockQueryBus as any);
});
describe('POST /reviews — createReview', () => {
it('dispatches CreateReviewCommand with correct parameters', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1', rating: 5, comment: 'Tuyệt vời!' };
const expected = { id: 'rev-1', rating: 5, targetType: 'agent', targetId: 'agent-1', createdAt: '2026-01-01T00:00:00.000Z' };
mockCommandBus.execute.mockResolvedValue(expected);
const result = await controller.createReview(dto as any, mockUser as any);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(CreateReviewCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
expect(cmd.userId).toBe('user-1');
expect(cmd.targetType).toBe('agent');
expect(cmd.targetId).toBe('agent-1');
expect(cmd.rating).toBe(5);
expect(cmd.comment).toBe('Tuyệt vời!');
expect(result).toEqual(expected);
});
it('passes null comment when not provided', async () => {
const dto = { targetType: 'property', targetId: 'prop-1', rating: 3 };
mockCommandBus.execute.mockResolvedValue({ id: 'rev-2' });
await controller.createReview(dto as any, mockUser as any);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
expect(cmd.comment).toBeNull();
});
});
describe('GET /reviews — getReviewsByTarget', () => {
it('dispatches GetReviewsByTargetQuery with defaults', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1' };
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getReviewsByTarget(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetReviewsByTargetQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
expect(query.targetType).toBe('agent');
expect(query.targetId).toBe('agent-1');
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
expect(result).toEqual(expected);
});
it('passes custom page and limit', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1', page: 3, limit: 10 };
mockQueryBus.execute.mockResolvedValue({ data: [] });
await controller.getReviewsByTarget(dto as any);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
expect(query.page).toBe(3);
expect(query.limit).toBe(10);
});
});
describe('GET /reviews/stats — getStats', () => {
it('dispatches GetAverageRatingQuery', async () => {
const dto = { targetType: 'agent', targetId: 'agent-1' };
const expected = { targetType: 'agent', targetId: 'agent-1', averageRating: 4.5, totalReviews: 10, distribution: {} };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getStats(dto as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetAverageRatingQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetAverageRatingQuery;
expect(query.targetType).toBe('agent');
expect(query.targetId).toBe('agent-1');
expect(result).toEqual(expected);
});
});
describe('GET /reviews/me — getMyReviews', () => {
it('dispatches GetReviewsByUserQuery with defaults', async () => {
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(expected);
const result = await controller.getMyReviews(mockUser as any);
expect(mockQueryBus.execute).toHaveBeenCalledWith(
expect.any(GetReviewsByUserQuery),
);
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByUserQuery;
expect(query.userId).toBe('user-1');
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
expect(result).toEqual(expected);
});
});
describe('DELETE /reviews/:id — deleteReview', () => {
it('dispatches DeleteReviewCommand and returns { deleted: true }', async () => {
mockCommandBus.execute.mockResolvedValue(undefined);
const result = await controller.deleteReview('rev-1', mockUser as any);
expect(mockCommandBus.execute).toHaveBeenCalledWith(
expect.any(DeleteReviewCommand),
);
const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteReviewCommand;
expect(cmd.reviewId).toBe('rev-1');
expect(cmd.userId).toBe('user-1');
expect(result).toEqual({ deleted: true });
});
});
});
Controller Test Pattern Insights:
- Organized by endpoint using describe blocks
- Tests dispatch with correct command/query types
- Verifies individual command properties after dispatch
- Tests default parameter handling
- Tests optional field handling (comment null)
- Tests return type verification
SUMMARY TABLE
| File | Module | Type | Key Tests |
|---|---|---|---|
| prisma-inquiry.repository.ts | Inquiries | Repository | findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent, toDomain |
| inquiries.controller.ts | Inquiries | Controller | POST create, GET by listing, GET by agent, PATCH mark read, guard enforcement |
| create-inquiry.dto.ts | Inquiries | DTO | listingId, message (max 2000), phone (optional) |
| list-inquiries.dto.ts | Inquiries | DTO | page/limit validation, type transformation |
| prisma-lead.repository.ts | Leads | Repository | CRUD, findByAgent with status filter, getStatsByAgent aggregation |
| lead-score.vo.ts | Leads | Value Object | Range validation (0-100), error messages |
| leads.controller.ts | Leads | Controller | POST create, GET list, GET stats, PATCH status, DELETE, class-level @Roles |
| create-lead.dto.ts | Leads | DTO | name, phone, email, source, score (0-100), notes |
| list-leads.dto.ts | Leads | DTO | status enum validation, pagination |
| update-lead-status.dto.ts | Leads | DTO | status enum validation |
| prisma-review.repository.ts | Reviews | Repository | findById, findByUserAndTarget, CRUD, pagination, getStats with distribution |
| rating.vo.ts | Reviews | Value Object | Integer range (1-5), error messages |
| reviews.controller.ts | Reviews | Controller | POST create, GET by target, GET stats, GET me, DELETE, mixed auth |
| create-review.dto.ts | Reviews | DTO | targetType, targetId, rating (1-5), comment (optional, max 2000) |
| list-reviews.dto.ts | Reviews | DTO | ListReviewsByTargetDto, ReviewStatsDto, pagination |
TESTING PRIORITIES
High Priority (Critical Business Logic):
- Repository methods - data integrity, pagination accuracy
- Value objects - validation, error messages
- Controllers - command dispatch, parameter mapping
- DTOs - validation rules, type transformation
Medium Priority (Guard Rails):
- Optional field handling
- Null/undefined coercion (→ null)
- Pagination boundary checks
- Aggregation calculations (avg, sum, distribution)
Low Priority (Framework):
- Decorator validation (framework responsibility)
- Error serialization
- Swagger documentation