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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-11 01:38:45 +07:00
parent 8265130477
commit 97c7d58f5e
4 changed files with 290 additions and 0 deletions

View File

@@ -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<typeof vi.fn> };
let mockEventBus: { publish: ReturnType<typeof vi.fn> };
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',
}),
);
});
});

View File

@@ -0,0 +1,81 @@
import { UserDeactivatedListener } from '../listeners/user-deactivated.listener';
describe('UserDeactivatedListener', () => {
let listener: UserDeactivatedListener;
let mockPrisma: {
listing: { updateMany: ReturnType<typeof vi.fn> };
};
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn> };
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',
);
});
});

View File

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

View File

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