# So Sánh Handler Chi Tiết & Các Mẫu Code ## So Sánh Cấu Trúc Tệp ### Mẫu Handler Đã Được Kiểm Thử: approve-listing ``` approve-listing/ ├── approve-listing.command.ts (lớp đơn giản) ├── approve-listing.handler.ts (handler cần kiểm thử) └── (không có query.ts - đây là Command, không phải Query) Tệp kiểm thử: └── approve-listing.handler.spec.ts ``` ### Handler Chưa Được Kiểm Thử: reject-listing ``` reject-listing/ ├── reject-listing.command.ts (lớp đơn giản) ├── reject-listing.handler.ts (CẦN KIỂM THỬ) └── (không có query.ts - đây là Command, không phải Query) Tệp kiểm thử: └── ❌ THIẾU: reject-listing.handler.spec.ts ``` --- ## So Sánh Handler Đặt Cạnh Nhau ### Handler APPROVE Listing: ```typescript @CommandHandler(ApproveListingCommand) export class ApproveListingHandler implements ICommandHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly eventBus: EventBus, ) {} async execute(command: ApproveListingCommand): Promise { // 1. Find listing const listing = await this.listingRepo.findById(command.listingId); if (!listing) { throw new NotFoundException('Listing không tồn tại'); } // 2. Check status if (listing.status !== 'PENDING_REVIEW') { throw new ValidationException( `Listing đang ở trạng thái ${listing.status}, chỉ có thể phê duyệt listing đang chờ duyệt`, { currentStatus: listing.status }, ); } // 3. Apply domain logic listing.approve(command.notes); // 4. Persist await this.listingRepo.update(listing); // 5. Publish event this.eventBus.publish( new ListingApprovedEvent(listing.id, command.adminId, command.notes), ); // 6. Return result return { listingId: listing.id, status: 'ACTIVE', message: 'Listing đã được phê duyệt', }; } } ``` ### Handler REJECT Listing (mẫu gần như giống hệt): ```typescript @CommandHandler(RejectListingCommand) export class RejectListingHandler implements ICommandHandler { constructor( @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository, private readonly eventBus: EventBus, ) {} async execute(command: RejectListingCommand): Promise { // 1. Find listing const listing = await this.listingRepo.findById(command.listingId); if (!listing) { throw new NotFoundException('Listing không tồn tại'); } // 2. Check status (giống như approve!) if (listing.status !== 'PENDING_REVIEW') { throw new ValidationException( `Listing đang ở trạng thái ${listing.status}, chỉ có thể từ chối listing đang chờ duyệt`, { currentStatus: listing.status }, ); } // 3. Apply domain logic (phương thức khác: reject thay vì approve) listing.reject(command.reason); // 4. Persist await this.listingRepo.update(listing); // 5. Publish event (loại event khác) this.eventBus.publish( new ListingRejectedEvent(listing.id, command.adminId, command.reason), ); // 6. Return result (trạng thái khác) return { listingId: listing.id, status: 'REJECTED', message: 'Listing đã bị từ chối', }; } } ``` ### Sự Khác Biệt: | Khía cạnh | Approve | Reject | |--------|---------|--------| | Phương thức domain | `listing.approve()` | `listing.reject()` | | Event | `ListingApprovedEvent` | `ListingRejectedEvent` | | Trạng thái kết quả | `'ACTIVE'` | `'REJECTED'` | | Thông báo kết quả | `'Listing đã được phê duyệt'` | `'Listing đã bị từ chối'` | --- ## Hướng Dẫn Code Kiểm Thử ### Kiểm Thử ApproveListingHandler: ```typescript describe('ApproveListingHandler', () => { let handler: ApproveListingHandler; let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; let mockEventBus: { publish: ReturnType }; // THIẾT LẬP: Tạo mock mới cho mỗi test beforeEach(() => { mockListingRepo = { findById: vi.fn(), findByIdWithProperty: vi.fn(), save: vi.fn(), update: vi.fn(), search: vi.fn(), findByStatus: vi.fn(), findBySellerId: vi.fn(), }; mockEventBus = { publish: vi.fn() }; // Khởi tạo handler với các mock handler = new ApproveListingHandler( mockListingRepo as any, mockEventBus as any, ); }); // TEST 1: Luồng bình thường - Phê duyệt thành công it('approves a pending listing successfully', async () => { // Arrange: Tạo một thực thể listing ở trạng thái PENDING_REVIEW const listing = createPendingListing(); mockListingRepo.findById.mockResolvedValue(listing); mockListingRepo.update.mockResolvedValue(undefined); // Act: Thực thi lệnh const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good'); const result = await handler.execute(command); // Assert: Kiểm tra kết quả expect(result.status).toBe('ACTIVE'); expect(result.listingId).toBe('listing-1'); // Assert: Kiểm tra các hiệu ứng phụ expect(mockListingRepo.update).toHaveBeenCalledTimes(1); expect(mockEventBus.publish).toHaveBeenCalled(); }); // TEST 2: Lỗi - Không tìm thấy listing it('throws NotFoundException when listing does not exist', async () => { // Arrange: Mock trả về null (không tìm thấy) mockListingRepo.findById.mockResolvedValue(null); // Act & Assert: Kỳ vọng ngoại lệ const command = new ApproveListingCommand('nonexistent', 'admin-1'); await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại'); }); // TEST 3: Lỗi - Sai trạng thái it('throws ValidationException when listing is not pending review', async () => { // Arrange: Tạo listing KHÔNG ở trạng thái PENDING_REVIEW const price = Price.create(500_000_000n).unwrap(); const listing = ListingEntity.createNew( 'listing-1', 'prop-1', 'seller-1', 'SALE', price, 80, ); listing.clearDomainEvents(); mockListingRepo.findById.mockResolvedValue(listing); // Act & Assert: Kỳ vọng ngoại lệ const command = new ApproveListingCommand('listing-1', 'admin-1'); await expect(handler.execute(command)).rejects.toThrow(/trạng thái/); }); }); ``` ### Cách điều chỉnh cho RejectListingHandler: 1. **Thay đổi import:** ```typescript import { RejectListingCommand } from '../commands/reject-listing/reject-listing.command'; import { RejectListingHandler } from '../commands/reject-listing/reject-listing.handler'; // Giữ nguyên tất cả phần còn lại ``` 2. **Thay đổi Test 1 (Luồng bình thường):** ```typescript it('rejects a pending listing successfully', async () => { const listing = createPendingListing(); mockListingRepo.findById.mockResolvedValue(listing); mockListingRepo.update.mockResolvedValue(undefined); const command = new RejectListingCommand('listing-1', 'admin-1', 'Too many issues'); const result = await handler.execute(command); expect(result.status).toBe('REJECTED'); // Đổi từ 'ACTIVE' expect(result.message).toContain('từ chối'); // Thay đổi câu kiểm tra expect(mockListingRepo.update).toHaveBeenCalledTimes(1); expect(mockEventBus.publish).toHaveBeenCalled(); }); ``` 3. **Test 2 & 3 gần như giữ nguyên** (chỉ thay đổi tên import) --- ## So Sánh Query Handler ### Query Handler Đã Được Kiểm Thử: get-dashboard-stats ```typescript @QueryHandler(GetDashboardStatsQuery) export class GetDashboardStatsHandler implements IQueryHandler { constructor( @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, ) {} async execute(query: GetDashboardStatsQuery): Promise { return this.adminQueryRepo.getDashboardStats(); } } ``` ### Query Handler Chưa Được Kiểm Thử: get-revenue-stats ```typescript @QueryHandler(GetRevenueStatsQuery) export class GetRevenueStatsHandler implements IQueryHandler { constructor( @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository, ) {} async execute(query: GetRevenueStatsQuery): Promise { // SỰ KHÁC BIỆT CHÍNH: Truyền tham số query vào phương thức repo return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy); } } ``` ### Mẫu Kiểm Thử Query Handler: ```typescript describe('GetRevenueStatsHandler', () => { let handler: GetRevenueStatsHandler; let mockAdminQueryRepo: { [K in keyof IAdminQueryRepository]: ReturnType }; beforeEach(() => { mockAdminQueryRepo = { getModerationQueue: vi.fn(), getDashboardStats: vi.fn(), getRevenueStats: vi.fn(), // Cái này sẽ được kiểm thử getUsers: vi.fn(), }; handler = new GetRevenueStatsHandler(mockAdminQueryRepo as any); }); it('returns revenue stats for date range', async () => { // Arrange: Dữ liệu mock const mockStats: RevenueStatsItem[] = [ { period: '2024-04', totalRevenue: 50000000n, subscriptionRevenue: 30000000n, listingFeeRevenue: 15000000n, featuredListingRevenue: 5000000n, transactionCount: 125, }, ]; mockAdminQueryRepo.getRevenueStats.mockResolvedValue(mockStats); // Act const startDate = new Date('2024-04-01'); const endDate = new Date('2024-04-30'); const query = new GetRevenueStatsQuery(startDate, endDate, 'month'); const result = await handler.execute(query); // Assert: Kiểm tra kết quả expect(result).toEqual(mockStats); // Assert: Kiểm tra các tham số được truyền đúng expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledWith( startDate, endDate, 'month' ); expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledTimes(1); }); it('supports day grouping', async () => { mockAdminQueryRepo.getRevenueStats.mockResolvedValue([]); const query = new GetRevenueStatsQuery( new Date('2024-04-01'), new Date('2024-04-30'), 'day' // Tham số đã thay đổi ); await handler.execute(query); expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledWith( expect.any(Date), expect.any(Date), 'day' // Kiểm tra 'day' đã được truyền vào ); }); it('returns empty array when no data', async () => { mockAdminQueryRepo.getRevenueStats.mockResolvedValue([]); const query = new GetRevenueStatsQuery( new Date('2024-01-01'), new Date('2024-01-01') ); const result = await handler.execute(query); expect(result).toEqual([]); expect(result.length).toBe(0); }); }); ``` --- ## So Sánh Listener ### UserBannedListener (Đã Được Kiểm Thử): ```typescript @Injectable() export class UserBannedListener { constructor( private readonly commandBus: CommandBus, private readonly prisma: PrismaService, private readonly logger: LoggerService, ) {} @OnEvent('user.banned', { async: true }) async handle(event: UserBannedEvent): Promise { this.logger.log(`Handling user.banned for user ${event.aggregateId}`, 'UserBannedListener'); // Vô hiệu hóa các listing const deactivated = await this.prisma.listing.updateMany({ where: { sellerId: event.aggregateId, status: { in: ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] }, }, data: { status: 'EXPIRED' }, }); this.logger.log( `Deactivated ${deactivated.count} listings for banned user ${event.aggregateId}`, 'UserBannedListener', ); // Gửi thông báo email const user = await this.prisma.user.findUnique({ where: { id: event.aggregateId }, select: { id: true, email: true }, }); if (user?.email) { await this.commandBus.execute( new SendNotificationCommand( user.id, 'EMAIL', 'user.banned', { reason: event.reason }, user.email, ), ); } } } ``` ### UserDeactivatedListener (Chưa Được Kiểm Thử): ```typescript @Injectable() export class UserDeactivatedListener { constructor( private readonly prisma: PrismaService, private readonly logger: LoggerService, ) {} @OnEvent('user.deactivated', { async: true }) async handle(event: UserDeactivatedEvent): Promise { this.logger.log(`Handling user.deactivated for user ${event.aggregateId}`, 'UserDeactivatedListener'); // Tương tự UserBannedListener nhưng: // 1. KHÔNG có CommandBus (đơn giản hơn) // 2. KHÔNG có thông báo email // 3. Danh sách trạng thái khác: ['ACTIVE', 'PENDING_REVIEW'] (không có DRAFT) const deactivated = await this.prisma.listing.updateMany({ where: { sellerId: event.aggregateId, status: { in: ['ACTIVE', 'PENDING_REVIEW'] }, }, data: { status: 'EXPIRED' }, }); this.logger.log( `Expired ${deactivated.count} listings for deactivated user ${event.aggregateId}`, 'UserDeactivatedListener', ); } } ``` ### Sự Khác Biệt Chính: | Khía cạnh | UserBanned | UserDeactivated | |--------|-----------|-----------------| | Tên event | `'user.banned'` | `'user.deactivated'` | | Có CommandBus | ✅ Có (để gửi email) | ❌ Không | | Danh sách trạng thái | `['ACTIVE', 'PENDING_REVIEW', 'DRAFT']` | `['ACTIVE', 'PENDING_REVIEW']` | | Gửi thông báo | ✅ Có (email) | ❌ Không | | Độ phức tạp | Cao hơn | Thấp hơn | ### Mẫu Kiểm Thử Listener (đơn giản hóa cho UserDeactivated): ```typescript describe('UserDeactivatedListener', () => { let listener: UserDeactivatedListener; let mockPrisma: { listing: { updateMany: ReturnType }; }; let mockLogger: { log: ReturnType }; beforeEach(() => { mockPrisma = { listing: { updateMany: vi.fn().mockResolvedValue({ count: 5 }) }, }; mockLogger = { log: vi.fn() }; listener = new UserDeactivatedListener(mockPrisma as any, mockLogger as any); }); it('expires all active and pending review listings for deactivated user', async () => { await listener.handle({ aggregateId: 'user-123', eventName: 'user.deactivated', occurredAt: new Date(), }); expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({ where: { sellerId: 'user-123', status: { in: ['ACTIVE', 'PENDING_REVIEW'] }, }, data: { status: 'EXPIRED' }, }); }); it('logs handling start and result', async () => { await listener.handle({ aggregateId: 'user-123', eventName: 'user.deactivated', occurredAt: new Date(), }); expect(mockLogger.log).toHaveBeenCalledTimes(2); expect(mockLogger.log).toHaveBeenNthCalledWith( 1, expect.stringContaining('user-123'), 'UserDeactivatedListener' ); expect(mockLogger.log).toHaveBeenNthCalledWith( 2, expect.stringContaining('Expired 5 listings'), 'UserDeactivatedListener' ); }); it('handles zero listings case', async () => { mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 }); await listener.handle({ aggregateId: 'user-xyz', eventName: 'user.deactivated', occurredAt: new Date(), }); expect(mockLogger.log).toHaveBeenNthCalledWith( 2, expect.stringContaining('Expired 0 listings'), 'UserDeactivatedListener' ); }); }); ```