diff --git a/apps/api/src/modules/listings/application/__tests__/delete-listing.handler.spec.ts b/apps/api/src/modules/listings/application/__tests__/delete-listing.handler.spec.ts new file mode 100644 index 0000000..c666d5d --- /dev/null +++ b/apps/api/src/modules/listings/application/__tests__/delete-listing.handler.spec.ts @@ -0,0 +1,120 @@ +import { ListingEntity } from '@modules/listings/domain/entities/listing.entity'; +import { type IListingRepository } from '@modules/listings/domain/repositories/listing.repository'; +import { Price } from '@modules/listings/domain/value-objects/price.vo'; +import { ForbiddenException, NotFoundException } from '@modules/shared'; +import { DeleteListingCommand } from '../commands/delete-listing/delete-listing.command'; +import { DeleteListingHandler } from '../commands/delete-listing/delete-listing.handler'; + +function createListing(id = 'listing-1', sellerId = 'seller-1'): ListingEntity { + const price = Price.create(2_000_000_000n).unwrap(); + const listing = ListingEntity.createNew(id, 'prop-1', sellerId, 'SALE', price, 80); + listing.clearDomainEvents(); + return listing; +} + +describe('DeleteListingHandler', () => { + let handler: DeleteListingHandler; + let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; + + beforeEach(() => { + mockListingRepo = { + findById: vi.fn(), + findByIdWithProperty: vi.fn(), + save: vi.fn(), + update: vi.fn(), + delete: vi.fn().mockResolvedValue(undefined), + search: vi.fn(), + findByStatus: vi.fn(), + findBySellerId: vi.fn(), + }; + + handler = new DeleteListingHandler(mockListingRepo as unknown as IListingRepository); + }); + + it('deletes the listing when caller is the owner', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new DeleteListingCommand('listing-1', 'seller-1'); + await handler.execute(command); + + expect(mockListingRepo.findById).toHaveBeenCalledWith('listing-1'); + expect(mockListingRepo.delete).toHaveBeenCalledTimes(1); + expect(mockListingRepo.delete).toHaveBeenCalledWith('listing-1'); + }); + + it('deletes the listing when caller is an admin (override)', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new DeleteListingCommand('listing-1', 'admin-9', 'ADMIN'); + await handler.execute(command); + + expect(mockListingRepo.delete).toHaveBeenCalledTimes(1); + expect(mockListingRepo.delete).toHaveBeenCalledWith('listing-1'); + }); + + it('throws NotFoundException when listing does not exist', async () => { + mockListingRepo.findById.mockResolvedValue(null); + + const command = new DeleteListingCommand('missing', 'seller-1'); + + await expect(handler.execute(command)).rejects.toBeInstanceOf(NotFoundException); + expect(mockListingRepo.delete).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenException when caller is neither owner nor admin', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new DeleteListingCommand('listing-1', 'someone-else', 'USER'); + + await expect(handler.execute(command)).rejects.toBeInstanceOf(ForbiddenException); + await expect(handler.execute(command)).rejects.toThrow(/người bán hoặc quản trị viên/); + expect(mockListingRepo.delete).not.toHaveBeenCalled(); + }); + + it('throws ForbiddenException when userRole is undefined and caller is not the seller', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const command = new DeleteListingCommand('listing-1', 'guest'); + + await expect(handler.execute(command)).rejects.toBeInstanceOf(ForbiddenException); + expect(mockListingRepo.delete).not.toHaveBeenCalled(); + }); + + it('propagates repository errors so the underlying $transaction rollback surfaces to the caller', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + // PrismaListingRepository.delete wraps savedListing/inquiry/priceHistory/order/transaction/listing + // deletes in a single prisma.$transaction([...]). If any step throws, the whole tx rolls back + // and the error bubbles up — the handler must not swallow it. + const txError = new Error('simulated tx step failure (rollback expected)'); + mockListingRepo.delete.mockRejectedValueOnce(txError); + + const command = new DeleteListingCommand('listing-1', 'seller-1'); + + await expect(handler.execute(command)).rejects.toBe(txError); + expect(mockListingRepo.delete).toHaveBeenCalledTimes(1); + }); + + it('only invokes the repository delete after authorization passes', async () => { + const listing = createListing('listing-1', 'seller-1'); + mockListingRepo.findById.mockResolvedValue(listing); + + const order: string[] = []; + mockListingRepo.findById.mockImplementationOnce(async () => { + order.push('findById'); + return listing; + }); + mockListingRepo.delete.mockImplementationOnce(async () => { + order.push('delete'); + }); + + await handler.execute(new DeleteListingCommand('listing-1', 'seller-1')); + + expect(order).toEqual(['findById', 'delete']); + }); +}); diff --git a/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts index 5956fe5..6e6393f 100644 --- a/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts +++ b/apps/api/src/modules/listings/application/commands/delete-listing/delete-listing.handler.ts @@ -24,6 +24,11 @@ export class DeleteListingHandler implements ICommandHandler