Files
goodgo-platform/docs/audits/DETAILED_HANDLER_COMPARISON.md
Ho Ngoc Hai 11f2bf26e6
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
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

16 KiB

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:

@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):

@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:

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:

    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):

    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

@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

@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:

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ử):

@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ử):

@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):

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