From 97c7d58f5ed8d656affab44be6d9a4ab4942def7 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Sat, 11 Apr 2026 01:38:45 +0700 Subject: [PATCH] test(api): add unit tests for admin, leads, and reviews modules Add missing test coverage: - reject-listing handler spec - user-deactivated listener spec - lead-score value object spec - rating value object spec Co-Authored-By: Paperclip --- .../__tests__/reject-listing.handler.spec.ts | 105 ++++++++++++++++++ .../user-deactivated.listener.spec.ts | 81 ++++++++++++++ .../domain/__tests__/lead-score.vo.spec.ts | 51 +++++++++ .../domain/__tests__/rating.vo.spec.ts | 53 +++++++++ 4 files changed, 290 insertions(+) create mode 100644 apps/api/src/modules/admin/application/__tests__/reject-listing.handler.spec.ts create mode 100644 apps/api/src/modules/admin/application/__tests__/user-deactivated.listener.spec.ts create mode 100644 apps/api/src/modules/leads/domain/__tests__/lead-score.vo.spec.ts create mode 100644 apps/api/src/modules/reviews/domain/__tests__/rating.vo.spec.ts diff --git a/apps/api/src/modules/admin/application/__tests__/reject-listing.handler.spec.ts b/apps/api/src/modules/admin/application/__tests__/reject-listing.handler.spec.ts new file mode 100644 index 0000000..e853d3a --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/reject-listing.handler.spec.ts @@ -0,0 +1,105 @@ +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 { RejectListingCommand } from '../commands/reject-listing/reject-listing.command'; +import { RejectListingHandler } from '../commands/reject-listing/reject-listing.handler'; + +function createPendingListing(id = 'listing-1'): ListingEntity { + const price = Price.create(1_000_000_000n).unwrap(); + const listing = ListingEntity.createNew( + id, 'prop-1', 'seller-1', 'SALE', price, 100, 'agent-1', + ); + listing.submitForReview(); + listing.clearDomainEvents(); + return listing; +} + +describe('RejectListingHandler', () => { + let handler: RejectListingHandler; + let mockListingRepo: { [K in keyof IListingRepository]: ReturnType }; + let mockEventBus: { publish: ReturnType }; + + 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() }; + + handler = new RejectListingHandler( + mockListingRepo as any, + mockEventBus as any, + ); + }); + + 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', 'Vi phạm chính sách'); + const result = await handler.execute(command); + + expect(result.status).toBe('REJECTED'); + expect(result.listingId).toBe('listing-1'); + expect(result.message).toBe('Listing đã bị từ chối'); + expect(mockListingRepo.update).toHaveBeenCalledTimes(1); + expect(mockEventBus.publish).toHaveBeenCalled(); + }); + + it('throws NotFoundException when listing does not exist', async () => { + mockListingRepo.findById.mockResolvedValue(null); + + const command = new RejectListingCommand('nonexistent', 'admin-1', 'Lý do'); + + await expect(handler.execute(command)).rejects.toThrow('Listing không tồn tại'); + }); + + it('throws ValidationException when listing is not pending review', async () => { + 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); + + const command = new RejectListingCommand('listing-1', 'admin-1', 'Lý do'); + + await expect(handler.execute(command)).rejects.toThrow(/trạng thái/); + }); + + it('passes rejection reason to listing.reject()', async () => { + const listing = createPendingListing(); + const rejectSpy = vi.spyOn(listing, 'reject'); + mockListingRepo.findById.mockResolvedValue(listing); + mockListingRepo.update.mockResolvedValue(undefined); + + const command = new RejectListingCommand('listing-1', 'admin-1', 'Nội dung không phù hợp'); + await handler.execute(command); + + expect(rejectSpy).toHaveBeenCalledWith('Nội dung không phù hợp'); + }); + + it('publishes ListingRejectedEvent with correct data', async () => { + const listing = createPendingListing('listing-99'); + mockListingRepo.findById.mockResolvedValue(listing); + mockListingRepo.update.mockResolvedValue(undefined); + + const command = new RejectListingCommand('listing-99', 'admin-42', 'Spam'); + await handler.execute(command); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + aggregateId: 'listing-99', + adminId: 'admin-42', + reason: 'Spam', + }), + ); + }); +}); diff --git a/apps/api/src/modules/admin/application/__tests__/user-deactivated.listener.spec.ts b/apps/api/src/modules/admin/application/__tests__/user-deactivated.listener.spec.ts new file mode 100644 index 0000000..f01d4d1 --- /dev/null +++ b/apps/api/src/modules/admin/application/__tests__/user-deactivated.listener.spec.ts @@ -0,0 +1,81 @@ +import { UserDeactivatedListener } from '../listeners/user-deactivated.listener'; + +describe('UserDeactivatedListener', () => { + let listener: UserDeactivatedListener; + let mockPrisma: { + listing: { updateMany: ReturnType }; + }; + let mockLogger: { log: ReturnType; warn: ReturnType }; + + beforeEach(() => { + mockPrisma = { + listing: { updateMany: vi.fn().mockResolvedValue({ count: 5 }) }, + }; + mockLogger = { log: vi.fn(), warn: vi.fn() }; + + listener = new UserDeactivatedListener( + mockPrisma as any, + mockLogger as any, + ); + }); + + it('expires ACTIVE and PENDING_REVIEW listings for deactivated user', async () => { + await listener.handle({ + aggregateId: 'user-1', + eventName: 'user.deactivated', + occurredAt: new Date(), + } as any); + + expect(mockPrisma.listing.updateMany).toHaveBeenCalledWith({ + where: { + sellerId: 'user-1', + status: { in: ['ACTIVE', 'PENDING_REVIEW'] }, + }, + data: { status: 'EXPIRED' }, + }); + }); + + it('logs the deactivation handling', async () => { + await listener.handle({ + aggregateId: 'user-42', + eventName: 'user.deactivated', + occurredAt: new Date(), + } as any); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('user-42'), + 'UserDeactivatedListener', + ); + }); + + it('logs the number of expired listings', async () => { + mockPrisma.listing.updateMany.mockResolvedValue({ count: 3 }); + + await listener.handle({ + aggregateId: 'user-1', + eventName: 'user.deactivated', + occurredAt: new Date(), + } as any); + + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('3'), + 'UserDeactivatedListener', + ); + }); + + it('handles user with no listings gracefully', async () => { + mockPrisma.listing.updateMany.mockResolvedValue({ count: 0 }); + + await listener.handle({ + aggregateId: 'user-no-listings', + eventName: 'user.deactivated', + occurredAt: new Date(), + } as any); + + expect(mockPrisma.listing.updateMany).toHaveBeenCalled(); + expect(mockLogger.log).toHaveBeenCalledWith( + expect.stringContaining('0'), + 'UserDeactivatedListener', + ); + }); +}); diff --git a/apps/api/src/modules/leads/domain/__tests__/lead-score.vo.spec.ts b/apps/api/src/modules/leads/domain/__tests__/lead-score.vo.spec.ts new file mode 100644 index 0000000..4c151ad --- /dev/null +++ b/apps/api/src/modules/leads/domain/__tests__/lead-score.vo.spec.ts @@ -0,0 +1,51 @@ +import { LeadScore } from '../value-objects/lead-score.vo'; + +describe('LeadScore Value Object', () => { + it('should create a valid lead score', () => { + const result = LeadScore.create(75); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(75); + }); + + it('should accept minimum score of 0', () => { + const result = LeadScore.create(0); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(0); + }); + + it('should accept maximum score of 100', () => { + const result = LeadScore.create(100); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(100); + }); + + it('should reject negative score', () => { + const result = LeadScore.create(-1); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100'); + }); + + it('should reject score above 100', () => { + const result = LeadScore.create(101); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Điểm lead phải từ 0 đến 100'); + }); + + it('should accept decimal scores', () => { + const result = LeadScore.create(50.5); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(50.5); + }); + + it('should accept boundary score of 1', () => { + const result = LeadScore.create(1); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(1); + }); + + it('should accept boundary score of 99', () => { + const result = LeadScore.create(99); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(99); + }); +}); diff --git a/apps/api/src/modules/reviews/domain/__tests__/rating.vo.spec.ts b/apps/api/src/modules/reviews/domain/__tests__/rating.vo.spec.ts new file mode 100644 index 0000000..42ad6ce --- /dev/null +++ b/apps/api/src/modules/reviews/domain/__tests__/rating.vo.spec.ts @@ -0,0 +1,53 @@ +import { Rating } from '../value-objects/rating.vo'; + +describe('Rating Value Object', () => { + it('should create a valid rating of 1', () => { + const result = Rating.create(1); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(1); + }); + + it('should create a valid rating of 5', () => { + const result = Rating.create(5); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(5); + }); + + it('should create a valid rating of 3', () => { + const result = Rating.create(3); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(3); + }); + + it('should reject rating of 0', () => { + const result = Rating.create(0); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Đánh giá phải từ 1 đến 5 sao'); + }); + + it('should reject rating above 5', () => { + const result = Rating.create(6); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Đánh giá phải từ 1 đến 5 sao'); + }); + + it('should reject negative rating', () => { + const result = Rating.create(-1); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Đánh giá phải từ 1 đến 5 sao'); + }); + + it('should reject non-integer rating', () => { + const result = Rating.create(3.5); + expect(result.isErr).toBe(true); + expect(result.unwrapErr()).toBe('Đánh giá phải từ 1 đến 5 sao'); + }); + + it('should create all valid integer ratings (1-5)', () => { + for (let i = 1; i <= 5; i++) { + const result = Rating.create(i); + expect(result.isOk).toBe(true); + expect(result.unwrap().value).toBe(i); + } + }); +});