Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
1841 lines
58 KiB
Markdown
1841 lines
58 KiB
Markdown
# Báo Cáo Phân Tích Độ Phủ Kiểm Thử
|
|
## GoodGo Platform API — Các Tệp Nguồn Chưa Được Kiểm Thử
|
|
|
|
**Ngày tạo:** 2026-04-11
|
|
**Thư mục làm việc:** `/Users/velikho/Desktop/WORKING/goodgo-platform-ai/apps/api/`
|
|
|
|
---
|
|
|
|
## Tóm Tắt Điều Hành
|
|
|
|
Báo cáo này liệt kê **17 tệp nguồn chưa được kiểm thử** trong các module inquiries, leads, và reviews, cùng toàn bộ phần triển khai của chúng. Các tệp được phân thành bốn nhóm:
|
|
|
|
1. **Repository Hạ Tầng** (3 tệp) - Lớp truy cập dữ liệu Prisma
|
|
2. **Value Object Miền** (2 tệp) - Đối tượng xác thực LeadScore và Rating
|
|
3. **DTO Trình Bày** (10 tệp) - Các lớp xác thực request/response
|
|
4. **Controller Trình Bày** (2 tệp) - Bộ xử lý endpoint HTTP
|
|
|
|
Hai tệp kiểm thử hiện có được cung cấp như các mẫu tham khảo, minh họa các phương pháp hay nhất cho kiểm thử đơn vị handler và controller.
|
|
|
|
---
|
|
|
|
# PHẦN 1: MODULE INQUIRIES
|
|
|
|
## 1.1 Prisma Inquiry Repository
|
|
|
|
**Tệp:** `src/modules/inquiries/infrastructure/repositories/prisma-inquiry.repository.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { type Inquiry as PrismaInquiry } from '@prisma/client';
|
|
import { type PrismaService } from '@modules/shared';
|
|
import { InquiryEntity } from '../../domain/entities/inquiry.entity';
|
|
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
|
import { type IInquiryRepository, type PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
|
|
|
@Injectable()
|
|
export class PrismaInquiryRepository implements IInquiryRepository {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async findById(id: string): Promise<InquiryEntity | null> {
|
|
const inquiry = await this.prisma.inquiry.findUnique({ where: { id } });
|
|
return inquiry ? this.toDomain(inquiry) : null;
|
|
}
|
|
|
|
async save(entity: InquiryEntity): Promise<void> {
|
|
await this.prisma.inquiry.create({
|
|
data: {
|
|
id: entity.id,
|
|
listingId: entity.listingId,
|
|
userId: entity.userId,
|
|
message: entity.message,
|
|
phone: entity.phone,
|
|
isRead: entity.isRead,
|
|
},
|
|
});
|
|
}
|
|
|
|
async markAsRead(id: string): Promise<void> {
|
|
await this.prisma.inquiry.update({
|
|
where: { id },
|
|
data: { isRead: true },
|
|
});
|
|
}
|
|
|
|
async findByListing(
|
|
listingId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<InquiryReadDto>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { listingId };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.inquiry.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
listing: { select: { id: true, property: { select: { title: true } } } },
|
|
user: { select: { id: true, fullName: true, phone: true } },
|
|
},
|
|
}),
|
|
this.prisma.inquiry.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
listingId: r.listingId,
|
|
listingTitle: r.listing.property.title,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
userPhone: r.user.phone,
|
|
message: r.message,
|
|
phone: r.phone,
|
|
isRead: r.isRead,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async findByAgent(
|
|
agentId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<InquiryReadDto>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { listing: { agentId } };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.inquiry.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: {
|
|
listing: { select: { id: true, property: { select: { title: true } } } },
|
|
user: { select: { id: true, fullName: true, phone: true } },
|
|
},
|
|
}),
|
|
this.prisma.inquiry.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
listingId: r.listingId,
|
|
listingTitle: r.listing.property.title,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
userPhone: r.user.phone,
|
|
message: r.message,
|
|
phone: r.phone,
|
|
isRead: r.isRead,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async countUnreadByAgent(agentId: string): Promise<number> {
|
|
return this.prisma.inquiry.count({
|
|
where: {
|
|
isRead: false,
|
|
listing: { agentId },
|
|
},
|
|
});
|
|
}
|
|
|
|
private toDomain(raw: PrismaInquiry): InquiryEntity {
|
|
return new InquiryEntity(
|
|
raw.id,
|
|
{
|
|
listingId: raw.listingId,
|
|
userId: raw.userId,
|
|
message: raw.message,
|
|
phone: raw.phone,
|
|
isRead: raw.isRead,
|
|
},
|
|
raw.createdAt,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Phương Thức Cần Kiểm Thử:**
|
|
- `findById()` - Trả về inquiry theo ID hoặc null nếu không tìm thấy
|
|
- `save()` - Tạo bản ghi inquiry mới
|
|
- `markAsRead()` - Cập nhật cờ isRead
|
|
- `findByListing()` - Truy vấn phân trang theo listing, bao gồm các quan hệ
|
|
- `findByAgent()` - Truy vấn phân trang qua quan hệ agent của listing
|
|
- `countUnreadByAgent()` - Truy vấn tổng hợp đếm số lượng chưa đọc
|
|
- `toDomain()` - Bộ ánh xạ riêng tư chuyển đổi model Prisma sang domain entity
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Tất cả phương thức với đầu vào hợp lệ
|
|
- Trả về null (không có bản ghi phù hợp)
|
|
- Các trường hợp biên của phân trang (giới hạn trang, giới hạn số lượng)
|
|
- Độ chính xác ánh xạ dữ liệu (ngày ISO, kết nối quan hệ)
|
|
|
|
---
|
|
|
|
## 1.2 Inquiries Controller
|
|
|
|
**Tệp:** `src/modules/inquiries/presentation/controllers/inquiries.controller.ts`
|
|
|
|
```typescript
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { type JwtPayload, CurrentUser, Roles, JwtAuthGuard, RolesGuard } from '@modules/auth';
|
|
import { CreateInquiryCommand } from '../../application/commands/create-inquiry/create-inquiry.command';
|
|
import { type CreateInquiryResult } from '../../application/commands/create-inquiry/create-inquiry.handler';
|
|
import { MarkInquiryReadCommand } from '../../application/commands/mark-inquiry-read/mark-inquiry-read.command';
|
|
import { GetInquiriesByAgentQuery } from '../../application/queries/get-inquiries-by-agent/get-inquiries-by-agent.query';
|
|
import { GetInquiriesByListingQuery } from '../../application/queries/get-inquiries-by-listing/get-inquiries-by-listing.query';
|
|
import { type InquiryReadDto } from '../../domain/repositories/inquiry-read.dto';
|
|
import { type PaginatedResult } from '../../domain/repositories/inquiry.repository';
|
|
import { type CreateInquiryDto } from '../dto/create-inquiry.dto';
|
|
import { type ListInquiriesDto } from '../dto/list-inquiries.dto';
|
|
|
|
@ApiTags('inquiries')
|
|
@Controller('inquiries')
|
|
export class InquiriesController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Create an inquiry for a listing' })
|
|
@ApiResponse({ status: 201, description: 'Inquiry created successfully' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 404, description: 'Listing not found' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post()
|
|
async createInquiry(
|
|
@Body() dto: CreateInquiryDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<CreateInquiryResult> {
|
|
return this.commandBus.execute(
|
|
new CreateInquiryCommand(
|
|
user.sub,
|
|
dto.listingId,
|
|
dto.message,
|
|
dto.phone ?? null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'List inquiries by listing' })
|
|
@ApiParam({ name: 'listingId', description: 'Listing ID' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of inquiries' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('listing/:listingId')
|
|
async getByListing(
|
|
@Param('listingId') listingId: string,
|
|
@Query() dto: ListInquiriesDto,
|
|
): Promise<PaginatedResult<InquiryReadDto>> {
|
|
return this.queryBus.execute(
|
|
new GetInquiriesByListingQuery(
|
|
listingId,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'List inquiries for current agent' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of inquiries for agent' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Forbidden — not an agent' })
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('AGENT')
|
|
@Get('agent/me')
|
|
async getMyInquiries(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Query() dto: ListInquiriesDto,
|
|
): Promise<PaginatedResult<InquiryReadDto>> {
|
|
return this.queryBus.execute(
|
|
new GetInquiriesByAgentQuery(
|
|
user.sub,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Mark inquiry as read' })
|
|
@ApiParam({ name: 'id', description: 'Inquiry ID' })
|
|
@ApiResponse({ status: 200, description: 'Inquiry marked as read' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Forbidden — not the listing agent' })
|
|
@ApiResponse({ status: 404, description: 'Inquiry not found' })
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('AGENT')
|
|
@Patch(':id/read')
|
|
async markAsRead(
|
|
@Param('id') id: string,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ success: boolean }> {
|
|
await this.commandBus.execute(
|
|
new MarkInquiryReadCommand(id, user.sub),
|
|
);
|
|
return { success: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Endpoint Cần Kiểm Thử:**
|
|
- `POST /inquiries` - Tạo inquiry với số điện thoại tùy chọn
|
|
- `GET /inquiries/listing/:listingId` - Danh sách theo listing có phân trang
|
|
- `GET /inquiries/agent/me` - Danh sách cho agent đã xác thực với guard vai trò AGENT
|
|
- `PATCH /inquiries/:id/read` - Đánh dấu đã đọc với guard vai trò
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Gửi lệnh/truy vấn đến bus với các tham số đúng
|
|
- Giá trị phân trang mặc định (page: 1, limit: 20)
|
|
- Xử lý phone null (chuyển thành null khi không cung cấp)
|
|
- Kiểm tra thực thi guard/decorator (JwtAuthGuard, RolesGuard, @Roles('AGENT'))
|
|
|
|
---
|
|
|
|
## 1.3 Create Inquiry DTO
|
|
|
|
**Tệp:** `src/modules/inquiries/presentation/dto/create-inquiry.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `listingId` - Chuỗi bắt buộc
|
|
- `message` - Chuỗi bắt buộc, tối đa 2000 ký tự
|
|
- `phone` - Chuỗi tùy chọn
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Đầu vào hợp lệ với tất cả các trường
|
|
- Đầu vào hợp lệ không có phone
|
|
- Thiếu các trường bắt buộc
|
|
- Message vượt quá 2000 ký tự
|
|
|
|
---
|
|
|
|
## 1.4 List Inquiries DTO
|
|
|
|
**Tệp:** `src/modules/inquiries/presentation/dto/list-inquiries.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `page` - Số nguyên tùy chọn, tối thiểu 1
|
|
- `limit` - Số nguyên tùy chọn, tối thiểu 1, tối đa 100
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Phân trang hợp lệ (page: 1, limit: 20)
|
|
- Phân trang tùy chỉnh
|
|
- Không hợp lệ: page < 1, limit < 1, limit > 100
|
|
- Chuyển đổi kiểu dữ liệu (chuỗi sang số)
|
|
|
|
---
|
|
|
|
# PHẦN 2: MODULE LEADS
|
|
|
|
## 2.1 Prisma Lead Repository
|
|
|
|
**Tệp:** `src/modules/leads/infrastructure/repositories/prisma-lead.repository.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { type Lead as PrismaLead } from '@prisma/client';
|
|
import { type PrismaService } from '@modules/shared';
|
|
import { LeadEntity, type LeadStatus } from '../../domain/entities/lead.entity';
|
|
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
|
import { type ILeadRepository, type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
|
import { LeadScore } from '../../domain/value-objects/lead-score.vo';
|
|
|
|
@Injectable()
|
|
export class PrismaLeadRepository implements ILeadRepository {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async findById(id: string): Promise<LeadEntity | null> {
|
|
const lead = await this.prisma.lead.findUnique({ where: { id } });
|
|
return lead ? this.toDomain(lead) : null;
|
|
}
|
|
|
|
async save(entity: LeadEntity): Promise<void> {
|
|
await this.prisma.lead.create({
|
|
data: {
|
|
id: entity.id,
|
|
agentId: entity.agentId,
|
|
name: entity.name,
|
|
phone: entity.phone,
|
|
email: entity.email,
|
|
source: entity.source,
|
|
score: entity.score?.value ?? null,
|
|
notes: entity.notes as never,
|
|
status: entity.status,
|
|
},
|
|
});
|
|
}
|
|
|
|
async update(entity: LeadEntity): Promise<void> {
|
|
await this.prisma.lead.update({
|
|
where: { id: entity.id },
|
|
data: {
|
|
status: entity.status,
|
|
score: entity.score?.value ?? null,
|
|
notes: entity.notes as never,
|
|
},
|
|
});
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.prisma.lead.delete({ where: { id } });
|
|
}
|
|
|
|
async findByAgent(
|
|
agentId: string,
|
|
status: string | null,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<LeadReadDto>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where: Record<string, unknown> = { agentId };
|
|
if (status) {
|
|
where['status'] = status;
|
|
}
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.lead.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
}),
|
|
this.prisma.lead.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
agentId: r.agentId,
|
|
name: r.name,
|
|
phone: r.phone,
|
|
email: r.email,
|
|
source: r.source,
|
|
score: r.score,
|
|
notes: r.notes,
|
|
status: r.status,
|
|
createdAt: r.createdAt.toISOString(),
|
|
updatedAt: r.updatedAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async getStatsByAgent(agentId: string): Promise<LeadStatsData> {
|
|
const leads = await this.prisma.lead.findMany({
|
|
where: { agentId },
|
|
select: { status: true, score: true },
|
|
});
|
|
|
|
const totalLeads = leads.length;
|
|
const byStatus: Record<string, number> = {};
|
|
|
|
let scoreSum = 0;
|
|
let scoreCount = 0;
|
|
let convertedCount = 0;
|
|
|
|
for (const lead of leads) {
|
|
byStatus[lead.status] = (byStatus[lead.status] ?? 0) + 1;
|
|
if (lead.score !== null) {
|
|
scoreSum += lead.score;
|
|
scoreCount++;
|
|
}
|
|
if (lead.status === 'CONVERTED') {
|
|
convertedCount++;
|
|
}
|
|
}
|
|
|
|
return {
|
|
totalLeads,
|
|
byStatus,
|
|
conversionRate: totalLeads > 0
|
|
? Math.round((convertedCount / totalLeads) * 10000) / 100
|
|
: 0,
|
|
avgScore: scoreCount > 0
|
|
? Math.round((scoreSum / scoreCount) * 10) / 10
|
|
: null,
|
|
};
|
|
}
|
|
|
|
private toDomain(raw: PrismaLead): LeadEntity {
|
|
let score: LeadScore | null = null;
|
|
if (raw.score !== null) {
|
|
score = LeadScore.create(raw.score).unwrap();
|
|
}
|
|
|
|
return new LeadEntity(
|
|
raw.id,
|
|
{
|
|
agentId: raw.agentId,
|
|
name: raw.name,
|
|
phone: raw.phone,
|
|
email: raw.email,
|
|
source: raw.source,
|
|
score,
|
|
notes: raw.notes,
|
|
status: raw.status as LeadStatus,
|
|
},
|
|
raw.createdAt,
|
|
raw.updatedAt,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Phương Thức Cần Kiểm Thử:**
|
|
- `findById()` - Trả về lead hoặc null
|
|
- `save()` - Tạo lead với điểm tùy chọn
|
|
- `update()` - Cập nhật trạng thái, điểm, ghi chú
|
|
- `delete()` - Xóa lead
|
|
- `findByAgent()` - Phân trang với bộ lọc trạng thái tùy chọn
|
|
- `getStatsByAgent()` - Tổng hợp theo trạng thái, tính tỷ lệ chuyển đổi và điểm trung bình
|
|
- `toDomain()` - Ánh xạ model Prisma bao gồm khởi tạo LeadScore VO
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Các thao tác CRUD
|
|
- Xử lý điểm tùy chọn (null và giá trị hợp lệ)
|
|
- Lọc trạng thái trong truy vấn phân trang
|
|
- Tính toán thống kê (không có lead, không có điểm, độ chính xác tỷ lệ chuyển đổi)
|
|
- Các trường hợp biên của phân trang
|
|
|
|
---
|
|
|
|
## 2.2 Lead Score Value Object
|
|
|
|
**Tệp:** `src/modules/leads/domain/value-objects/lead-score.vo.ts`
|
|
|
|
```typescript
|
|
import { Result, ValueObject } from '@modules/shared';
|
|
|
|
interface LeadScoreProps {
|
|
value: number;
|
|
}
|
|
|
|
export class LeadScore extends ValueObject<LeadScoreProps> {
|
|
get value(): number { return this.props.value; }
|
|
|
|
static create(value: number): Result<LeadScore, string> {
|
|
if (value < 0 || value > 100) {
|
|
return Result.err('Điểm lead phải từ 0 đến 100');
|
|
}
|
|
return Result.ok(new LeadScore({ value }));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- Giá trị phải là số nguyên trong khoảng từ 0 đến 100 (bao gồm hai đầu)
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Điểm hợp lệ (0, 50, 100)
|
|
- Không hợp lệ: âm, > 100, null, chuỗi
|
|
- Thông báo lỗi bằng tiếng Việt
|
|
- Tính bằng nhau của value object (phải có thể so sánh)
|
|
|
|
---
|
|
|
|
## 2.3 Leads Controller
|
|
|
|
**Tệp:** `src/modules/leads/presentation/controllers/leads.controller.ts`
|
|
|
|
```typescript
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Patch,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { type JwtPayload, CurrentUser, JwtAuthGuard, RolesGuard, Roles } from '@modules/auth';
|
|
import { CreateLeadCommand } from '../../application/commands/create-lead/create-lead.command';
|
|
import { type CreateLeadResult } from '../../application/commands/create-lead/create-lead.handler';
|
|
import { DeleteLeadCommand } from '../../application/commands/delete-lead/delete-lead.command';
|
|
import { UpdateLeadStatusCommand } from '../../application/commands/update-lead-status/update-lead-status.command';
|
|
import { GetLeadStatsQuery } from '../../application/queries/get-lead-stats/get-lead-stats.query';
|
|
import { GetLeadsByAgentQuery } from '../../application/queries/get-leads-by-agent/get-leads-by-agent.query';
|
|
import { type LeadReadDto } from '../../domain/repositories/lead-read.dto';
|
|
import { type LeadStatsData, type PaginatedResult } from '../../domain/repositories/lead.repository';
|
|
import { type CreateLeadDto } from '../dto/create-lead.dto';
|
|
import { type ListLeadsDto } from '../dto/list-leads.dto';
|
|
import { type UpdateLeadStatusDto } from '../dto/update-lead-status.dto';
|
|
|
|
@ApiTags('leads')
|
|
@ApiBearerAuth('JWT')
|
|
@Controller('leads')
|
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
|
@Roles('AGENT')
|
|
export class LeadsController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
@ApiOperation({ summary: 'Tạo lead mới' })
|
|
@ApiResponse({ status: 201, description: 'Lead đã được tạo thành công' })
|
|
@ApiResponse({ status: 400, description: 'Lỗi validation' })
|
|
@ApiResponse({ status: 401, description: 'Chưa xác thực' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@Post()
|
|
async createLead(
|
|
@Body() dto: CreateLeadDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<CreateLeadResult> {
|
|
return this.commandBus.execute(
|
|
new CreateLeadCommand(
|
|
user.sub,
|
|
dto.name,
|
|
dto.phone,
|
|
dto.email ?? null,
|
|
dto.source,
|
|
dto.score ?? null,
|
|
dto.notes ?? null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Danh sách lead của agent' })
|
|
@ApiResponse({ status: 200, description: 'Danh sách lead phân trang' })
|
|
@Get()
|
|
async getLeads(
|
|
@Query() dto: ListLeadsDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<PaginatedResult<LeadReadDto>> {
|
|
return this.queryBus.execute(
|
|
new GetLeadsByAgentQuery(
|
|
user.sub,
|
|
dto.status ?? null,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Thống kê lead của agent' })
|
|
@ApiResponse({ status: 200, description: 'Thống kê lead' })
|
|
@Get('stats')
|
|
async getStats(
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<LeadStatsData> {
|
|
return this.queryBus.execute(
|
|
new GetLeadStatsQuery(user.sub),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Cập nhật trạng thái lead' })
|
|
@ApiParam({ name: 'id', description: 'Lead ID' })
|
|
@ApiResponse({ status: 200, description: 'Trạng thái đã được cập nhật' })
|
|
@ApiResponse({ status: 400, description: 'Chuyển trạng thái không hợp lệ' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
|
|
@Patch(':id/status')
|
|
async updateStatus(
|
|
@Param('id') id: string,
|
|
@Body() dto: UpdateLeadStatusDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ updated: boolean }> {
|
|
await this.commandBus.execute(
|
|
new UpdateLeadStatusCommand(id, user.sub, dto.status),
|
|
);
|
|
return { updated: true };
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Xóa lead' })
|
|
@ApiParam({ name: 'id', description: 'Lead ID' })
|
|
@ApiResponse({ status: 200, description: 'Lead đã được xóa' })
|
|
@ApiResponse({ status: 403, description: 'Không có quyền' })
|
|
@ApiResponse({ status: 404, description: 'Không tìm thấy lead' })
|
|
@Delete(':id')
|
|
async deleteLead(
|
|
@Param('id') id: string,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ deleted: boolean }> {
|
|
await this.commandBus.execute(new DeleteLeadCommand(id, user.sub));
|
|
return { deleted: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Endpoint Cần Kiểm Thử:**
|
|
- `POST /leads` - Tạo lead với email, điểm, ghi chú tùy chọn
|
|
- `GET /leads` - Danh sách lead của agent với bộ lọc trạng thái tùy chọn
|
|
- `GET /leads/stats` - Tổng hợp thống kê
|
|
- `PATCH /leads/:id/status` - Cập nhật trạng thái lead
|
|
- `DELETE /leads/:id` - Xóa lead
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Guard cấp lớp (@Roles('AGENT') áp dụng cho tất cả phương thức)
|
|
- Xử lý trường tùy chọn (email, score, notes → null)
|
|
- Lọc trạng thái (null truyền qua, trạng thái cụ thể lọc kết quả)
|
|
- Ánh xạ tham số lệnh/truy vấn
|
|
- Xác minh kiểu trả về (updated: true, deleted: true)
|
|
|
|
---
|
|
|
|
## 2.4 Create Lead DTO
|
|
|
|
**Tệp:** `src/modules/leads/presentation/dto/create-lead.dto.ts`
|
|
|
|
```typescript
|
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|
import { IsEmail, IsNotEmpty, IsNumber, IsOptional, IsString, Max, Min } from 'class-validator';
|
|
|
|
export class CreateLeadDto {
|
|
@ApiProperty({ example: 'Nguyễn Văn A', description: 'Tên khách hàng tiềm năng' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
name!: string;
|
|
|
|
@ApiProperty({ example: '0901234567', description: 'Số điện thoại' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
phone!: string;
|
|
|
|
@ApiPropertyOptional({ example: 'nguyen@example.com', description: 'Email' })
|
|
@IsOptional()
|
|
@IsEmail()
|
|
email?: string;
|
|
|
|
@ApiProperty({ example: 'website', description: 'Nguồn lead' })
|
|
@IsString()
|
|
@IsNotEmpty()
|
|
source!: string;
|
|
|
|
@ApiPropertyOptional({ example: 75, description: 'Điểm lead (0-100)' })
|
|
@IsOptional()
|
|
@IsNumber()
|
|
@Min(0)
|
|
@Max(100)
|
|
score?: number;
|
|
|
|
@ApiPropertyOptional({ description: 'Ghi chú bổ sung' })
|
|
@IsOptional()
|
|
notes?: Record<string, unknown>;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `name` - Chuỗi bắt buộc
|
|
- `phone` - Chuỗi bắt buộc
|
|
- `email` - Tùy chọn, phải đúng định dạng email
|
|
- `source` - Chuỗi bắt buộc
|
|
- `score` - Số tùy chọn, 0-100
|
|
- `notes` - Đối tượng tùy chọn
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Tất cả các trường bắt buộc có mặt
|
|
- Bỏ qua các trường tùy chọn
|
|
- Định dạng email không hợp lệ
|
|
- Điểm ngoài phạm vi
|
|
- Kiểu notes không hợp lệ
|
|
|
|
---
|
|
|
|
## 2.5 List Leads DTO
|
|
|
|
**Tệp:** `src/modules/leads/presentation/dto/list-leads.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `status` - Tùy chọn, phải là một trong: NEW, CONTACTED, QUALIFIED, NEGOTIATING, CONVERTED, LOST
|
|
- `page` - Số nguyên tùy chọn, tối thiểu 1
|
|
- `limit` - Số nguyên tùy chọn, 1-100
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Phân trang hợp lệ
|
|
- Giá trị trạng thái hợp lệ
|
|
- Trạng thái không hợp lệ
|
|
- Chuyển đổi kiểu dữ liệu (chuỗi sang số)
|
|
- Kiểm tra biên
|
|
|
|
---
|
|
|
|
## 2.6 Update Lead Status DTO
|
|
|
|
**Tệp:** `src/modules/leads/presentation/dto/update-lead-status.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `status` - Bắt buộc, phải là trạng thái lead hợp lệ
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Các chuyển đổi trạng thái hợp lệ
|
|
- Trạng thái không hợp lệ
|
|
- Thiếu trường status
|
|
|
|
---
|
|
|
|
# PHẦN 3: MODULE REVIEWS
|
|
|
|
## 3.1 Prisma Review Repository
|
|
|
|
**Tệp:** `src/modules/reviews/infrastructure/repositories/prisma-review.repository.ts`
|
|
|
|
```typescript
|
|
import { Injectable } from '@nestjs/common';
|
|
import { type Review as PrismaReview } from '@prisma/client';
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
import { PrismaService } from '@modules/shared';
|
|
import { ReviewEntity } from '../../domain/entities/review.entity';
|
|
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
|
import { type IReviewRepository, type PaginatedResult } from '../../domain/repositories/review.repository';
|
|
import { Rating } from '../../domain/value-objects/rating.vo';
|
|
|
|
@Injectable()
|
|
export class PrismaReviewRepository implements IReviewRepository {
|
|
constructor(private readonly prisma: PrismaService) {}
|
|
|
|
async findById(id: string): Promise<ReviewEntity | null> {
|
|
const review = await this.prisma.review.findUnique({ where: { id } });
|
|
return review ? this.toDomain(review) : null;
|
|
}
|
|
|
|
async findByUserAndTarget(
|
|
userId: string,
|
|
targetType: string,
|
|
targetId: string,
|
|
): Promise<ReviewEntity | null> {
|
|
const review = await this.prisma.review.findFirst({
|
|
where: { userId, targetType, targetId },
|
|
});
|
|
return review ? this.toDomain(review) : null;
|
|
}
|
|
|
|
async save(entity: ReviewEntity): Promise<void> {
|
|
await this.prisma.review.create({
|
|
data: {
|
|
id: entity.id,
|
|
userId: entity.userId,
|
|
targetType: entity.targetType,
|
|
targetId: entity.targetId,
|
|
rating: entity.rating.value,
|
|
comment: entity.comment,
|
|
},
|
|
});
|
|
}
|
|
|
|
async delete(id: string): Promise<void> {
|
|
await this.prisma.review.delete({ where: { id } });
|
|
}
|
|
|
|
async findByTarget(
|
|
targetType: string,
|
|
targetId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { targetType, targetId };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.review.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { id: true, fullName: true } } },
|
|
}),
|
|
this.prisma.review.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
targetType: r.targetType,
|
|
targetId: r.targetId,
|
|
rating: r.rating,
|
|
comment: r.comment,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async findByUserId(
|
|
userId: string,
|
|
page: number,
|
|
limit: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
const take = Math.min(limit, 100);
|
|
const skip = (page - 1) * take;
|
|
const where = { userId };
|
|
|
|
const [data, total] = await Promise.all([
|
|
this.prisma.review.findMany({
|
|
where,
|
|
skip,
|
|
take,
|
|
orderBy: { createdAt: 'desc' },
|
|
include: { user: { select: { id: true, fullName: true } } },
|
|
}),
|
|
this.prisma.review.count({ where }),
|
|
]);
|
|
|
|
return {
|
|
data: data.map((r) => ({
|
|
id: r.id,
|
|
userId: r.userId,
|
|
userName: r.user.fullName,
|
|
targetType: r.targetType,
|
|
targetId: r.targetId,
|
|
rating: r.rating,
|
|
comment: r.comment,
|
|
createdAt: r.createdAt.toISOString(),
|
|
})),
|
|
total,
|
|
page,
|
|
limit: take,
|
|
totalPages: Math.ceil(total / take),
|
|
};
|
|
}
|
|
|
|
async getStats(targetType: string, targetId: string): Promise<ReviewStatsData> {
|
|
const reviews = await this.prisma.review.findMany({
|
|
where: { targetType, targetId },
|
|
select: { rating: true },
|
|
});
|
|
|
|
const totalReviews = reviews.length;
|
|
const distribution: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0 };
|
|
let sum = 0;
|
|
|
|
for (const r of reviews) {
|
|
sum += r.rating;
|
|
distribution[r.rating] = (distribution[r.rating] ?? 0) + 1;
|
|
}
|
|
|
|
return {
|
|
targetType,
|
|
targetId,
|
|
averageRating: totalReviews > 0 ? Math.round((sum / totalReviews) * 10) / 10 : 0,
|
|
totalReviews,
|
|
distribution,
|
|
};
|
|
}
|
|
|
|
private toDomain(raw: PrismaReview): ReviewEntity {
|
|
const rating = Rating.create(raw.rating).unwrap();
|
|
return new ReviewEntity(
|
|
raw.id,
|
|
{
|
|
userId: raw.userId,
|
|
targetType: raw.targetType,
|
|
targetId: raw.targetId,
|
|
rating,
|
|
comment: raw.comment,
|
|
},
|
|
raw.createdAt,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Phương Thức Cần Kiểm Thử:**
|
|
- `findById()` - Trả về review hoặc null
|
|
- `findByUserAndTarget()` - Kiểm tra ràng buộc duy nhất
|
|
- `save()` - Tạo review với bình luận tùy chọn
|
|
- `delete()` - Xóa review
|
|
- `findByTarget()` - Danh sách phân trang với kết nối user
|
|
- `findByUserId()` - Danh sách phân trang theo user
|
|
- `getStats()` - Tính điểm đánh giá trung bình và phân phối
|
|
- `toDomain()` - Ánh xạ model Prisma bao gồm Rating VO
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Tất cả các thao tác CRUD
|
|
- Xử lý bình luận tùy chọn
|
|
- Phân trang với quan hệ (kết nối fullName của user)
|
|
- Thống kê với tính toán phân phối (thang 1-5)
|
|
- Trường hợp biên không có review
|
|
- Độ chính xác dữ liệu (ngày ISO, làm tròn thập phân)
|
|
|
|
---
|
|
|
|
## 3.2 Rating Value Object
|
|
|
|
**Tệp:** `src/modules/reviews/domain/value-objects/rating.vo.ts`
|
|
|
|
```typescript
|
|
import { Result, ValueObject } from '@modules/shared';
|
|
|
|
interface RatingProps {
|
|
value: number;
|
|
}
|
|
|
|
export class Rating extends ValueObject<RatingProps> {
|
|
get value(): number { return this.props.value; }
|
|
|
|
static create(value: number): Result<Rating, string> {
|
|
if (!Number.isInteger(value) || value < 1 || value > 5) {
|
|
return Result.err('Đánh giá phải từ 1 đến 5 sao');
|
|
}
|
|
return Result.ok(new Rating({ value }));
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- Phải là số nguyên trong khoảng từ 1 đến 5 (bao gồm hai đầu)
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Đánh giá hợp lệ (1-5)
|
|
- Không hợp lệ: 0, 6, -1, null, số thực (2.5), không phải số nguyên
|
|
- Xác minh thông báo lỗi
|
|
- Tạo value object và kiểm tra tính bằng nhau
|
|
|
|
---
|
|
|
|
## 3.3 Create Review DTO
|
|
|
|
**Tệp:** `src/modules/reviews/presentation/dto/create-review.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực:**
|
|
- `targetType` - Chuỗi bắt buộc (ví dụ: "agent", "property")
|
|
- `targetId` - Chuỗi bắt buộc
|
|
- `rating` - Số nguyên bắt buộc, 1-5
|
|
- `comment` - Chuỗi tùy chọn, tối đa 2000 ký tự
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Review hợp lệ với tất cả các trường
|
|
- Hợp lệ không có bình luận
|
|
- Thiếu các trường bắt buộc
|
|
- Đánh giá không hợp lệ (0, 6, 2.5)
|
|
- Bình luận vượt quá độ dài tối đa
|
|
|
|
---
|
|
|
|
## 3.4 List Reviews DTOs
|
|
|
|
**Tệp:** `src/modules/reviews/presentation/dto/list-reviews.dto.ts`
|
|
|
|
```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;
|
|
}
|
|
```
|
|
|
|
**Các Quy Tắc Xác Thực của ListReviewsByTargetDto:**
|
|
- `targetType` - Chuỗi bắt buộc
|
|
- `targetId` - Chuỗi bắt buộc
|
|
- `page` - Tùy chọn, tối thiểu 1
|
|
- `limit` - Tùy chọn, 1-100
|
|
|
|
**Các Quy Tắc Xác Thực của ReviewStatsDto:**
|
|
- `targetType` - Chuỗi bắt buộc
|
|
- `targetId` - Chuỗi bắt buộc
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Phân trang hợp lệ với các trường target bắt buộc
|
|
- Chuyển đổi kiểu dữ liệu
|
|
- Kiểm tra biên phân trang
|
|
|
|
---
|
|
|
|
## 3.5 Reviews Controller
|
|
|
|
**Tệp:** `src/modules/reviews/presentation/controllers/reviews.controller.ts`
|
|
|
|
```typescript
|
|
import {
|
|
Body,
|
|
Controller,
|
|
Delete,
|
|
Get,
|
|
Param,
|
|
Post,
|
|
Query,
|
|
UseGuards,
|
|
} from '@nestjs/common';
|
|
// eslint-disable-next-line @typescript-eslint/consistent-type-imports -- NestJS DI requires value imports for emitDecoratorMetadata
|
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
|
import {
|
|
ApiTags,
|
|
ApiOperation,
|
|
ApiResponse,
|
|
ApiBearerAuth,
|
|
ApiParam,
|
|
} from '@nestjs/swagger';
|
|
import { type JwtPayload, CurrentUser, JwtAuthGuard } from '@modules/auth';
|
|
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
|
import { type CreateReviewResult } from '../../application/commands/create-review/create-review.handler';
|
|
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
|
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
|
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
|
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
|
import { type ReviewItemData, type ReviewStatsData } from '../../domain/repositories/review-read.dto';
|
|
import { type PaginatedResult } from '../../domain/repositories/review.repository';
|
|
import { type CreateReviewDto } from '../dto/create-review.dto';
|
|
import { type ListReviewsByTargetDto, type ReviewStatsDto } from '../dto/list-reviews.dto';
|
|
|
|
@ApiTags('reviews')
|
|
@Controller('reviews')
|
|
export class ReviewsController {
|
|
constructor(
|
|
private readonly commandBus: CommandBus,
|
|
private readonly queryBus: QueryBus,
|
|
) {}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Create a review' })
|
|
@ApiResponse({ status: 201, description: 'Review created successfully' })
|
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 409, description: 'Already reviewed this target' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Post()
|
|
async createReview(
|
|
@Body() dto: CreateReviewDto,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<CreateReviewResult> {
|
|
return this.commandBus.execute(
|
|
new CreateReviewCommand(
|
|
user.sub,
|
|
dto.targetType,
|
|
dto.targetId,
|
|
dto.rating,
|
|
dto.comment ?? null,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'List reviews by target' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of reviews' })
|
|
@Get()
|
|
async getReviewsByTarget(
|
|
@Query() dto: ListReviewsByTargetDto,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
return this.queryBus.execute(
|
|
new GetReviewsByTargetQuery(
|
|
dto.targetType,
|
|
dto.targetId,
|
|
dto.page ?? 1,
|
|
dto.limit ?? 20,
|
|
),
|
|
);
|
|
}
|
|
|
|
@ApiOperation({ summary: 'Get aggregate rating stats for a target' })
|
|
@ApiResponse({ status: 200, description: 'Rating statistics' })
|
|
@Get('stats')
|
|
async getStats(
|
|
@Query() dto: ReviewStatsDto,
|
|
): Promise<ReviewStatsData> {
|
|
return this.queryBus.execute(
|
|
new GetAverageRatingQuery(dto.targetType, dto.targetId),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Get reviews by authenticated user' })
|
|
@ApiResponse({ status: 200, description: 'Paginated list of user reviews' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Get('me')
|
|
async getMyReviews(
|
|
@CurrentUser() user: JwtPayload,
|
|
@Query('page') page?: number,
|
|
@Query('limit') limit?: number,
|
|
): Promise<PaginatedResult<ReviewItemData>> {
|
|
return this.queryBus.execute(
|
|
new GetReviewsByUserQuery(user.sub, page ?? 1, limit ?? 20),
|
|
);
|
|
}
|
|
|
|
@ApiBearerAuth('JWT')
|
|
@ApiOperation({ summary: 'Delete own review' })
|
|
@ApiParam({ name: 'id', description: 'Review ID' })
|
|
@ApiResponse({ status: 200, description: 'Review deleted' })
|
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
|
@ApiResponse({ status: 403, description: 'Cannot delete another user\'s review' })
|
|
@ApiResponse({ status: 404, description: 'Review not found' })
|
|
@UseGuards(JwtAuthGuard)
|
|
@Delete(':id')
|
|
async deleteReview(
|
|
@Param('id') id: string,
|
|
@CurrentUser() user: JwtPayload,
|
|
): Promise<{ deleted: boolean }> {
|
|
await this.commandBus.execute(new DeleteReviewCommand(id, user.sub));
|
|
return { deleted: true };
|
|
}
|
|
}
|
|
```
|
|
|
|
**Các Endpoint Cần Kiểm Thử:**
|
|
- `POST /reviews` - Tạo với bình luận tùy chọn
|
|
- `GET /reviews` - Danh sách theo target (không yêu cầu xác thực)
|
|
- `GET /reviews/stats` - Thống kê cho target (không yêu cầu xác thực)
|
|
- `GET /reviews/me` - Danh sách review của user (yêu cầu xác thực)
|
|
- `DELETE /reviews/:id` - Xóa review (yêu cầu xác thực)
|
|
|
|
**Các Kịch Bản Kiểm Thử:**
|
|
- Gửi lệnh/truy vấn với các tham số đúng
|
|
- Phân trang mặc định (1, 20)
|
|
- Xử lý bình luận null
|
|
- Yêu cầu xác thực (JWT cho POST, GET /me, DELETE; không xác thực cho GET, GET/stats)
|
|
- Kiểu trả về (deleted: true)
|
|
|
|
---
|
|
|
|
# PHẦN 4: CÁC MẪU KIỂM THỬ THAM KHẢO
|
|
|
|
## 4.1 Ví Dụ Kiểm Thử Handler: Create Inquiry Handler
|
|
|
|
**Tệp:** `src/modules/inquiries/application/__tests__/create-inquiry.handler.spec.ts`
|
|
|
|
```typescript
|
|
import type { EventBus } from '@nestjs/cqrs';
|
|
import type { IInquiryRepository } from '../../domain/repositories/inquiry.repository';
|
|
import { CreateInquiryCommand } from '../commands/create-inquiry/create-inquiry.command';
|
|
import { CreateInquiryHandler } from '../commands/create-inquiry/create-inquiry.handler';
|
|
|
|
describe('CreateInquiryHandler', () => {
|
|
let handler: CreateInquiryHandler;
|
|
let mockInquiryRepo: { [K in keyof IInquiryRepository]: ReturnType<typeof vi.fn> };
|
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
|
let mockPrisma: {
|
|
listing: { findUnique: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockInquiryRepo = {
|
|
findById: vi.fn(),
|
|
save: vi.fn(),
|
|
markAsRead: vi.fn(),
|
|
findByListing: vi.fn(),
|
|
findByAgent: vi.fn(),
|
|
countUnreadByAgent: vi.fn(),
|
|
};
|
|
|
|
mockEventBus = { publish: vi.fn() };
|
|
|
|
mockPrisma = {
|
|
listing: { findUnique: vi.fn() },
|
|
};
|
|
|
|
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
|
|
|
handler = new CreateInquiryHandler(
|
|
mockInquiryRepo as any,
|
|
mockEventBus as unknown as EventBus,
|
|
mockPrisma as any,
|
|
mockLogger as any,
|
|
);
|
|
});
|
|
|
|
it('creates an inquiry successfully', async () => {
|
|
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
|
|
mockInquiryRepo.save.mockResolvedValue(undefined);
|
|
|
|
const command = new CreateInquiryCommand(
|
|
'user-1',
|
|
'listing-1',
|
|
'Tôi muốn xem nhà',
|
|
'0901234567',
|
|
);
|
|
|
|
const result = await handler.execute(command);
|
|
|
|
expect(result.id).toBeDefined();
|
|
expect(result.listingId).toBe('listing-1');
|
|
expect(result.createdAt).toBeDefined();
|
|
expect(mockInquiryRepo.save).toHaveBeenCalledTimes(1);
|
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws NotFoundException when listing not found', async () => {
|
|
mockPrisma.listing.findUnique.mockResolvedValue(null);
|
|
|
|
const command = new CreateInquiryCommand(
|
|
'user-1',
|
|
'listing-not-exist',
|
|
'Tôi muốn xem nhà',
|
|
null,
|
|
);
|
|
|
|
await expect(handler.execute(command)).rejects.toThrow(
|
|
"Listing with id 'listing-not-exist' not found",
|
|
);
|
|
expect(mockInquiryRepo.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('publishes domain events after saving', async () => {
|
|
mockPrisma.listing.findUnique.mockResolvedValue({ id: 'listing-1' });
|
|
mockInquiryRepo.save.mockResolvedValue(undefined);
|
|
|
|
const command = new CreateInquiryCommand(
|
|
'user-1',
|
|
'listing-1',
|
|
'Cho tôi hỏi giá',
|
|
null,
|
|
);
|
|
|
|
await handler.execute(command);
|
|
|
|
expect(mockEventBus.publish).toHaveBeenCalledTimes(1);
|
|
expect(mockEventBus.publish).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
eventName: 'inquiry.created',
|
|
listingId: 'listing-1',
|
|
userId: 'user-1',
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
```
|
|
|
|
**Nhận Xét về Mẫu:**
|
|
- Sử dụng `vi.fn()` từ Vitest để mock
|
|
- Mock tất cả các phương thức repository trong beforeEach
|
|
- Kiểm tra đường dẫn thành công với tất cả dependencies đã được mock
|
|
- Kiểm tra các kịch bản lỗi (NotFoundException)
|
|
- Xác minh các hiệu ứng phụ (publishing sự kiện)
|
|
- Sử dụng kiểm tra tham số chính xác hoặc matcher (`expect.objectContaining`)
|
|
|
|
---
|
|
|
|
## 4.2 Ví Dụ Kiểm Thử Handler: Create Lead Handler
|
|
|
|
**Tệp:** `src/modules/leads/application/__tests__/create-lead.handler.spec.ts`
|
|
|
|
```typescript
|
|
import type { EventBus } from '@nestjs/cqrs';
|
|
import type { ILeadRepository } from '../../domain/repositories/lead.repository';
|
|
import { CreateLeadCommand } from '../commands/create-lead/create-lead.command';
|
|
import { CreateLeadHandler } from '../commands/create-lead/create-lead.handler';
|
|
|
|
describe('CreateLeadHandler', () => {
|
|
let handler: CreateLeadHandler;
|
|
let mockLeadRepo: { [K in keyof ILeadRepository]: ReturnType<typeof vi.fn> };
|
|
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
|
|
let mockPrisma: {
|
|
agent: { findUnique: ReturnType<typeof vi.fn> };
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockLeadRepo = {
|
|
findById: vi.fn(),
|
|
save: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
findByAgent: vi.fn(),
|
|
getStatsByAgent: vi.fn(),
|
|
};
|
|
|
|
mockEventBus = { publish: vi.fn() };
|
|
|
|
mockPrisma = {
|
|
agent: { findUnique: vi.fn() },
|
|
};
|
|
|
|
const mockLogger = { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn(), verbose: vi.fn() };
|
|
|
|
handler = new CreateLeadHandler(
|
|
mockLeadRepo as any,
|
|
mockEventBus as unknown as EventBus,
|
|
mockPrisma as any,
|
|
mockLogger as any,
|
|
);
|
|
});
|
|
|
|
it('creates a lead successfully', async () => {
|
|
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
|
mockLeadRepo.save.mockResolvedValue(undefined);
|
|
|
|
const command = new CreateLeadCommand(
|
|
'user-1',
|
|
'Nguyễn Văn A',
|
|
'0901234567',
|
|
'a@example.com',
|
|
'WEBSITE',
|
|
75,
|
|
{ note: 'Interested in District 7' },
|
|
);
|
|
|
|
const result = await handler.execute(command);
|
|
|
|
expect(result.id).toBeDefined();
|
|
expect(result.status).toBe('NEW');
|
|
expect(result.createdAt).toBeDefined();
|
|
expect(mockLeadRepo.save).toHaveBeenCalledTimes(1);
|
|
expect(mockEventBus.publish).toHaveBeenCalled();
|
|
});
|
|
|
|
it('creates a lead with null score', async () => {
|
|
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
|
mockLeadRepo.save.mockResolvedValue(undefined);
|
|
|
|
const command = new CreateLeadCommand(
|
|
'user-1',
|
|
'Nguyễn Văn B',
|
|
'0907654321',
|
|
null,
|
|
'REFERRAL',
|
|
null,
|
|
null,
|
|
);
|
|
|
|
const result = await handler.execute(command);
|
|
|
|
expect(result.id).toBeDefined();
|
|
expect(result.status).toBe('NEW');
|
|
});
|
|
|
|
it('throws NotFoundException when agent not found', async () => {
|
|
mockPrisma.agent.findUnique.mockResolvedValue(null);
|
|
|
|
const command = new CreateLeadCommand(
|
|
'not-an-agent',
|
|
'Nguyễn Văn A',
|
|
'0901234567',
|
|
null,
|
|
'WEBSITE',
|
|
null,
|
|
null,
|
|
);
|
|
|
|
await expect(handler.execute(command)).rejects.toThrow(
|
|
"Agent with id 'not-an-agent' not found",
|
|
);
|
|
expect(mockLeadRepo.save).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('throws ValidationException for invalid score', async () => {
|
|
mockPrisma.agent.findUnique.mockResolvedValue({ id: 'agent-1', userId: 'user-1' });
|
|
|
|
const command = new CreateLeadCommand(
|
|
'user-1',
|
|
'Nguyễn Văn A',
|
|
'0901234567',
|
|
null,
|
|
'WEBSITE',
|
|
150,
|
|
null,
|
|
);
|
|
|
|
await expect(handler.execute(command)).rejects.toThrow(
|
|
'Điểm lead phải từ 0 đến 100',
|
|
);
|
|
expect(mockLeadRepo.save).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
```
|
|
|
|
**Ghi Chú Bổ Sung về Mẫu:**
|
|
- Kiểm tra xử lý tham số tùy chọn (score: null)
|
|
- Kiểm tra lỗi xác thực từ value object (LeadScore)
|
|
- Sử dụng cấu trúc mock thiết lập giống như inquiry handler
|
|
|
|
---
|
|
|
|
## 4.3 Ví Dụ Kiểm Thử Controller: Reviews Controller
|
|
|
|
**Tệp:** `src/modules/reviews/presentation/__tests__/reviews.controller.spec.ts`
|
|
|
|
```typescript
|
|
import { CreateReviewCommand } from '../../application/commands/create-review/create-review.command';
|
|
import { DeleteReviewCommand } from '../../application/commands/delete-review/delete-review.command';
|
|
import { GetAverageRatingQuery } from '../../application/queries/get-average-rating/get-average-rating.query';
|
|
import { GetReviewsByTargetQuery } from '../../application/queries/get-reviews-by-target/get-reviews-by-target.query';
|
|
import { GetReviewsByUserQuery } from '../../application/queries/get-reviews-by-user/get-reviews-by-user.query';
|
|
import { ReviewsController } from '../controllers/reviews.controller';
|
|
|
|
describe('ReviewsController', () => {
|
|
let controller: ReviewsController;
|
|
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
|
|
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
|
|
|
|
const mockUser = { sub: 'user-1', phone: '0901234567', role: 'BUYER' };
|
|
|
|
beforeEach(() => {
|
|
mockCommandBus = { execute: vi.fn() };
|
|
mockQueryBus = { execute: vi.fn() };
|
|
controller = new ReviewsController(mockCommandBus as any, mockQueryBus as any);
|
|
});
|
|
|
|
describe('POST /reviews — createReview', () => {
|
|
it('dispatches CreateReviewCommand with correct parameters', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1', rating: 5, comment: 'Tuyệt vời!' };
|
|
const expected = { id: 'rev-1', rating: 5, targetType: 'agent', targetId: 'agent-1', createdAt: '2026-01-01T00:00:00.000Z' };
|
|
mockCommandBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.createReview(dto as any, mockUser as any);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(CreateReviewCommand),
|
|
);
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
|
|
expect(cmd.userId).toBe('user-1');
|
|
expect(cmd.targetType).toBe('agent');
|
|
expect(cmd.targetId).toBe('agent-1');
|
|
expect(cmd.rating).toBe(5);
|
|
expect(cmd.comment).toBe('Tuyệt vời!');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('passes null comment when not provided', async () => {
|
|
const dto = { targetType: 'property', targetId: 'prop-1', rating: 3 };
|
|
mockCommandBus.execute.mockResolvedValue({ id: 'rev-2' });
|
|
|
|
await controller.createReview(dto as any, mockUser as any);
|
|
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as CreateReviewCommand;
|
|
expect(cmd.comment).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews — getReviewsByTarget', () => {
|
|
it('dispatches GetReviewsByTargetQuery with defaults', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1' };
|
|
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getReviewsByTarget(dto as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetReviewsByTargetQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
|
|
expect(query.targetType).toBe('agent');
|
|
expect(query.targetId).toBe('agent-1');
|
|
expect(query.page).toBe(1);
|
|
expect(query.limit).toBe(20);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
|
|
it('passes custom page and limit', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1', page: 3, limit: 10 };
|
|
mockQueryBus.execute.mockResolvedValue({ data: [] });
|
|
|
|
await controller.getReviewsByTarget(dto as any);
|
|
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByTargetQuery;
|
|
expect(query.page).toBe(3);
|
|
expect(query.limit).toBe(10);
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews/stats — getStats', () => {
|
|
it('dispatches GetAverageRatingQuery', async () => {
|
|
const dto = { targetType: 'agent', targetId: 'agent-1' };
|
|
const expected = { targetType: 'agent', targetId: 'agent-1', averageRating: 4.5, totalReviews: 10, distribution: {} };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getStats(dto as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetAverageRatingQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetAverageRatingQuery;
|
|
expect(query.targetType).toBe('agent');
|
|
expect(query.targetId).toBe('agent-1');
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('GET /reviews/me — getMyReviews', () => {
|
|
it('dispatches GetReviewsByUserQuery with defaults', async () => {
|
|
const expected = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
|
|
mockQueryBus.execute.mockResolvedValue(expected);
|
|
|
|
const result = await controller.getMyReviews(mockUser as any);
|
|
|
|
expect(mockQueryBus.execute).toHaveBeenCalledWith(
|
|
expect.any(GetReviewsByUserQuery),
|
|
);
|
|
const query = mockQueryBus.execute.mock.calls[0]![0] as GetReviewsByUserQuery;
|
|
expect(query.userId).toBe('user-1');
|
|
expect(query.page).toBe(1);
|
|
expect(query.limit).toBe(20);
|
|
expect(result).toEqual(expected);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /reviews/:id — deleteReview', () => {
|
|
it('dispatches DeleteReviewCommand and returns { deleted: true }', async () => {
|
|
mockCommandBus.execute.mockResolvedValue(undefined);
|
|
|
|
const result = await controller.deleteReview('rev-1', mockUser as any);
|
|
|
|
expect(mockCommandBus.execute).toHaveBeenCalledWith(
|
|
expect.any(DeleteReviewCommand),
|
|
);
|
|
const cmd = mockCommandBus.execute.mock.calls[0]![0] as DeleteReviewCommand;
|
|
expect(cmd.reviewId).toBe('rev-1');
|
|
expect(cmd.userId).toBe('user-1');
|
|
expect(result).toEqual({ deleted: true });
|
|
});
|
|
});
|
|
});
|
|
```
|
|
|
|
**Nhận Xét về Mẫu Kiểm Thử Controller:**
|
|
- Tổ chức theo endpoint sử dụng các khối describe
|
|
- Kiểm tra việc gửi với đúng kiểu lệnh/truy vấn
|
|
- Xác minh từng thuộc tính lệnh sau khi gửi
|
|
- Kiểm tra xử lý tham số mặc định
|
|
- Kiểm tra xử lý trường tùy chọn (comment null)
|
|
- Kiểm tra xác minh kiểu trả về
|
|
|
|
---
|
|
|
|
# BẢNG TÓM TẮT
|
|
|
|
| Tệp | Module | Loại | Các Kiểm Thử Chính |
|
|
|------|--------|------|-----------|
|
|
| prisma-inquiry.repository.ts | Inquiries | Repository | findById, save, markAsRead, findByListing, findByAgent, countUnreadByAgent, toDomain |
|
|
| inquiries.controller.ts | Inquiries | Controller | POST create, GET by listing, GET by agent, PATCH mark read, guard enforcement |
|
|
| create-inquiry.dto.ts | Inquiries | DTO | listingId, message (max 2000), phone (optional) |
|
|
| list-inquiries.dto.ts | Inquiries | DTO | page/limit validation, type transformation |
|
|
| prisma-lead.repository.ts | Leads | Repository | CRUD, findByAgent with status filter, getStatsByAgent aggregation |
|
|
| lead-score.vo.ts | Leads | Value Object | Range validation (0-100), error messages |
|
|
| leads.controller.ts | Leads | Controller | POST create, GET list, GET stats, PATCH status, DELETE, class-level @Roles |
|
|
| create-lead.dto.ts | Leads | DTO | name, phone, email, source, score (0-100), notes |
|
|
| list-leads.dto.ts | Leads | DTO | status enum validation, pagination |
|
|
| update-lead-status.dto.ts | Leads | DTO | status enum validation |
|
|
| prisma-review.repository.ts | Reviews | Repository | findById, findByUserAndTarget, CRUD, pagination, getStats with distribution |
|
|
| rating.vo.ts | Reviews | Value Object | Integer range (1-5), error messages |
|
|
| reviews.controller.ts | Reviews | Controller | POST create, GET by target, GET stats, GET me, DELETE, mixed auth |
|
|
| create-review.dto.ts | Reviews | DTO | targetType, targetId, rating (1-5), comment (optional, max 2000) |
|
|
| list-reviews.dto.ts | Reviews | DTO | ListReviewsByTargetDto, ReviewStatsDto, pagination |
|
|
|
|
---
|
|
|
|
# ƯU TIÊN KIỂM THỬ
|
|
|
|
**Ưu Tiên Cao (Logic Nghiệp Vụ Quan Trọng):**
|
|
1. Các phương thức Repository - tính toàn vẹn dữ liệu, độ chính xác phân trang
|
|
2. Value Object - xác thực, thông báo lỗi
|
|
3. Controller - gửi lệnh, ánh xạ tham số
|
|
4. DTO - quy tắc xác thực, chuyển đổi kiểu dữ liệu
|
|
|
|
**Ưu Tiên Trung Bình (Bảo Vệ An Toàn):**
|
|
1. Xử lý trường tùy chọn
|
|
2. Ép kiểu null/undefined (→ null)
|
|
3. Kiểm tra biên phân trang
|
|
4. Tính toán tổng hợp (trung bình, tổng, phân phối)
|
|
|
|
**Ưu Tiên Thấp (Framework):**
|
|
1. Xác thực decorator (trách nhiệm của framework)
|
|
2. Tuần tự hóa lỗi
|
|
3. Tài liệu Swagger
|