Files
goodgo-platform/docs/audits/DETAILED_HANDLER_COMPARISON.md
Ho Ngoc Hai b8512ebff4 docs: consolidate audit and analysis reports into docs/audits/
Move 36 root-level audit/analysis documents and 7 web app audit documents
into docs/audits/ directory to declutter the project root. Remove stale
EXPLORATION_SUMMARY.txt.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 01:37:50 +07:00

15 KiB

Detailed Handler Comparison & Code Patterns

File Structure Comparison

Tested Handler Pattern: approve-listing

approve-listing/
├── approve-listing.command.ts       (simple class)
├── approve-listing.handler.ts       (the handler to test)
└── (no query.ts - it's a Command, not a Query)

Test file:
└── approve-listing.handler.spec.ts

Untested Handler: reject-listing

reject-listing/
├── reject-listing.command.ts        (simple class)
├── reject-listing.handler.ts        (NEEDS TEST)
└── (no query.ts - it's a Command, not a Query)

Test file:
└── ❌ MISSING: reject-listing.handler.spec.ts

Side-by-Side Handler Comparison

APPROVE Listing Handler:

@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',
    };
  }
}

REJECT Listing Handler (virtually identical pattern):

@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 (same as 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 (different method: reject instead of approve)
    listing.reject(command.reason);
    
    // 4. Persist
    await this.listingRepo.update(listing);

    // 5. Publish event (different event type)
    this.eventBus.publish(
      new ListingRejectedEvent(listing.id, command.adminId, command.reason),
    );

    // 6. Return result (different status)
    return {
      listingId: listing.id,
      status: 'REJECTED',
      message: 'Listing đã bị từ chối',
    };
  }
}

Differences:

Aspect Approve Reject
Domain Method listing.approve() listing.reject()
Event ListingApprovedEvent ListingRejectedEvent
Result Status 'ACTIVE' 'REJECTED'
Result Message 'Listing đã được phê duyệt' 'Listing đã bị từ chối'

Test Code Walkthrough

ApproveListingHandler Test:

describe('ApproveListingHandler', () => {
  let handler: ApproveListingHandler;
  let mockListingRepo: { [K in keyof IListingRepository]: ReturnType<typeof vi.fn> };
  let mockEventBus: { publish: ReturnType<typeof vi.fn> };

  // SETUP: Create fresh mocks for each 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() };

    // Instantiate handler with mocks
    handler = new ApproveListingHandler(
      mockListingRepo as any,
      mockEventBus as any,
    );
  });

  // TEST 1: Happy Path - Successfully approve
  it('approves a pending listing successfully', async () => {
    // Arrange: Create a listing entity in PENDING_REVIEW state
    const listing = createPendingListing();
    mockListingRepo.findById.mockResolvedValue(listing);
    mockListingRepo.update.mockResolvedValue(undefined);

    // Act: Execute the command
    const command = new ApproveListingCommand('listing-1', 'admin-1', 'Looks good');
    const result = await handler.execute(command);

    // Assert: Verify result
    expect(result.status).toBe('ACTIVE');
    expect(result.listingId).toBe('listing-1');
    
    // Assert: Verify side effects
    expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
    expect(mockEventBus.publish).toHaveBeenCalled();
  });

  // TEST 2: Error - Listing not found
  it('throws NotFoundException when listing does not exist', async () => {
    // Arrange: Mock returns null (not found)
    mockListingRepo.findById.mockResolvedValue(null);

    // Act & Assert: Expect exception
    const command = new ApproveListingCommand('nonexistent', 'admin-1');
    await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại');
  });

  // TEST 3: Error - Wrong status
  it('throws ValidationException when listing is not pending review', async () => {
    // Arrange: Create listing NOT in PENDING_REVIEW status
    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: Expect exception
    const command = new ApproveListingCommand('listing-1', 'admin-1');
    await expect(handler.execute(command)).rejects.toThrow(/trạng thái/);
  });
});

How to adapt for RejectListingHandler:

  1. Import changes:

    import { RejectListingCommand } from '../commands/reject-listing/reject-listing.command';
    import { RejectListingHandler } from '../commands/reject-listing/reject-listing.handler';
    // Keep everything else the same
    
  2. Test 1 (Happy path) changes:

    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');  // Changed from 'ACTIVE'
      expect(result.message).toContain('từ chối');  // Changed assertion
      expect(mockListingRepo.update).toHaveBeenCalledTimes(1);
      expect(mockEventBus.publish).toHaveBeenCalled();
    });
    
  3. Tests 2 & 3 remain almost identical (only import names change)


Query Handler Comparison

Tested Query Handler: 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();
  }
}

Untested Query Handler: 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[]> {
    // KEY DIFFERENCE: Passes query params to repo method
    return this.adminQueryRepo.getRevenueStats(query.startDate, query.endDate, query.groupBy);
  }
}

Query Handler Test Pattern:

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(),  // This one will be tested
      getUsers: vi.fn(),
    };

    handler = new GetRevenueStatsHandler(mockAdminQueryRepo as any);
  });

  it('returns revenue stats for date range', async () => {
    // Arrange: Mock data
    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: Verify result
    expect(result).toEqual(mockStats);
    
    // Assert: Verify params passed correctly
    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'  // Changed parameter
    );
    await handler.execute(query);

    expect(mockAdminQueryRepo.getRevenueStats).toHaveBeenCalledWith(
      expect.any(Date),
      expect.any(Date),
      'day'  // Verify 'day' was passed
    );
  });

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

Listener Comparison

UserBannedListener (Tested):

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

    // Deactivate listings
    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',
    );

    // Send email notification
    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 (Untested):

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

    // Similar to UserBannedListener but:
    // 1. NO CommandBus (simpler)
    // 2. NO email notification
    // 3. Different status list: ['ACTIVE', 'PENDING_REVIEW'] (no 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',
    );
  }
}

Key Differences:

Aspect UserBanned UserDeactivated
Event name 'user.banned' 'user.deactivated'
Has CommandBus Yes (for email) No
Status list ['ACTIVE', 'PENDING_REVIEW', 'DRAFT'] ['ACTIVE', 'PENDING_REVIEW']
Sends notification Yes (email) No
Complexity Higher Lower

Listener Test Pattern (simplified for 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'
    );
  });
});