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:
@@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user