# 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<ApproveListingCommand> {
  constructor(
    @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: ApproveListingCommand): Promise<ApproveLis tingResult> {
    // 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<RejectListingCommand> {
  constructor(
    @Inject(LISTING_REPOSITORY) private readonly listingRepo: IListingRepository,
    private readonly eventBus: EventBus,
  ) {}

  async execute(command: RejectListingCommand): Promise<RejectListingResult> {
    // 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<typeof vi.fn> };
  let mockEventBus: { publish: ReturnType<typeof vi.fn> };

  // 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<GetDashboardStatsQuery> {
  constructor(
    @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
  ) {}

  async execute(query: GetDashboardStatsQuery): Promise<DashboardStats> {
    return this.adminQueryRepo.getDashboardStats();
  }
}
```

### Query Handler Chưa Được Kiểm Thử: get-revenue-stats
```typescript
@QueryHandler(GetRevenueStatsQuery)
export class GetRevenueStatsHandler implements IQueryHandler<GetRevenueStatsQuery> {
  constructor(
    @Inject(ADMIN_QUERY_REPOSITORY) private readonly adminQueryRepo: IAdminQueryRepository,
  ) {}

  async execute(query: GetRevenueStatsQuery): Promise<RevenueStatsItem[]> {
    // 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<typeof vi.fn> };

  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<void> {
    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<void> {
    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<typeof vi.fn> };
  };
  let mockLogger: { log: ReturnType<typeof vi.fn> };

  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'
    );
  });
});
```
