test(listings): cover delete-listing handler branches + tx contract (TEC-2923)
- Add delete-listing.handler.spec.ts: not-found, forbidden, owner happy path, admin override, tx rollback propagation, call ordering. - Annotate DeleteListingHandler with the repository atomicity contract; PrismaListingRepository.delete already wraps side-effects in prisma.$transaction([...]) so handler stays a thin orchestrator. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof vi.fn> };
|
||||
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -24,6 +24,11 @@ export class DeleteListingHandler implements ICommandHandler<DeleteListingComman
|
||||
);
|
||||
}
|
||||
|
||||
// Atomicity contract: IListingRepository.delete MUST wrap all side-effects
|
||||
// (savedListing, inquiry, priceHistory, order, transaction, listing) in a
|
||||
// single prisma.$transaction([...]) so that a failure in any step rolls back
|
||||
// the entire delete. See PrismaListingRepository.delete for the concrete
|
||||
// implementation. Tests: delete-listing.handler.spec.ts (rollback case).
|
||||
await this.listingRepo.delete(command.listingId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user