test: increase test coverage for listings, auth, and search modules

Add 33 new test files to reach coverage targets:
- Listings: 13 → 28 test files (50%+)
- Auth: 21 → 36 test files (50%+)
- Search: 10 → 13 test files (59%+)

New tests cover domain entities, value objects, services, guards,
decorators, DTOs, repositories, controllers, and event handlers.
Total: 204 test files, 1178 tests passing.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-10 21:39:20 +07:00
parent 75a608031b
commit 1aad9b9f95
31 changed files with 2991 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
import { describe, it, expect } from 'vitest';
import { CreateListingCommand } from '../commands/create-listing/create-listing.command';
import { ModerateListingCommand } from '../commands/moderate-listing/moderate-listing.command';
import { UpdateListingStatusCommand } from '../commands/update-listing-status/update-listing-status.command';
import { UploadMediaCommand } from '../commands/upload-media/upload-media.command';
describe('CreateListingCommand', () => {
it('should store all required properties', () => {
const command = new CreateListingCommand(
'seller-1',
'SALE',
5_000_000_000n,
'APARTMENT',
'Căn hộ đẹp Quận 1',
'Mô tả chi tiết',
'123 Nguyễn Huệ',
'Bến Nghé',
'Quận 1',
'Hồ Chí Minh',
10.7769,
106.7009,
80,
);
expect(command.sellerId).toBe('seller-1');
expect(command.transactionType).toBe('SALE');
expect(command.priceVND).toBe(5_000_000_000n);
expect(command.propertyType).toBe('APARTMENT');
expect(command.title).toBe('Căn hộ đẹp Quận 1');
expect(command.description).toBe('Mô tả chi tiết');
expect(command.address).toBe('123 Nguyễn Huệ');
expect(command.ward).toBe('Bến Nghé');
expect(command.district).toBe('Quận 1');
expect(command.city).toBe('Hồ Chí Minh');
expect(command.latitude).toBe(10.7769);
expect(command.longitude).toBe(106.7009);
expect(command.areaM2).toBe(80);
});
it('should store optional properties', () => {
const command = new CreateListingCommand(
'seller-1',
'RENT',
3_000_000_000n,
'TOWNHOUSE',
'Nhà phố cho thuê',
'Nhà phố 3 tầng mặt tiền rộng',
'456 Lê Lợi',
'Phường 1',
'Quận 3',
'Hồ Chí Minh',
10.78,
106.69,
120,
100, // usableAreaM2
3, // bedrooms
2, // bathrooms
3, // floors
undefined, // floor
undefined, // totalFloors
'EAST', // direction
2020, // yearBuilt
'Sổ hồng', // legalStatus
{ parking: true }, // amenities
[], // nearbyPOIs
500, // metroDistanceM
undefined, // projectName
'agent-1', // agentId
25_000_000n, // rentPriceMonthly
2.5, // commissionPct
);
expect(command.usableAreaM2).toBe(100);
expect(command.bedrooms).toBe(3);
expect(command.bathrooms).toBe(2);
expect(command.floors).toBe(3);
expect(command.direction).toBe('EAST');
expect(command.yearBuilt).toBe(2020);
expect(command.legalStatus).toBe('Sổ hồng');
expect(command.agentId).toBe('agent-1');
expect(command.rentPriceMonthly).toBe(25_000_000n);
expect(command.commissionPct).toBe(2.5);
});
it('should default optional properties to undefined', () => {
const command = new CreateListingCommand(
'seller-1', 'SALE', 1_000_000_000n,
'LAND', 'Đất nền', 'Mô tả',
'789 ABC', 'Ward', 'District', 'City',
10.0, 106.0, 200,
);
expect(command.usableAreaM2).toBeUndefined();
expect(command.bedrooms).toBeUndefined();
expect(command.agentId).toBeUndefined();
expect(command.rentPriceMonthly).toBeUndefined();
});
});
describe('ModerateListingCommand', () => {
it('should store all properties for approve', () => {
const command = new ModerateListingCommand(
'listing-1',
'admin-1',
'approve',
95,
'Tin đăng hợp lệ',
);
expect(command.listingId).toBe('listing-1');
expect(command.moderatorId).toBe('admin-1');
expect(command.action).toBe('approve');
expect(command.moderationScore).toBe(95);
expect(command.notes).toBe('Tin đăng hợp lệ');
});
it('should store properties for reject without score', () => {
const command = new ModerateListingCommand(
'listing-2',
'admin-1',
'reject',
undefined,
'Nội dung vi phạm',
);
expect(command.action).toBe('reject');
expect(command.moderationScore).toBeUndefined();
expect(command.notes).toBe('Nội dung vi phạm');
});
it('should default optional fields to undefined', () => {
const command = new ModerateListingCommand(
'listing-3',
'admin-1',
'approve',
);
expect(command.moderationScore).toBeUndefined();
expect(command.notes).toBeUndefined();
});
});
describe('UpdateListingStatusCommand', () => {
it('should store all properties', () => {
const command = new UpdateListingStatusCommand(
'listing-1',
'ACTIVE',
'user-1',
'Đã xác minh thông tin',
);
expect(command.listingId).toBe('listing-1');
expect(command.newStatus).toBe('ACTIVE');
expect(command.userId).toBe('user-1');
expect(command.moderationNotes).toBe('Đã xác minh thông tin');
});
it('should default moderationNotes to undefined', () => {
const command = new UpdateListingStatusCommand(
'listing-2',
'SOLD',
'user-2',
);
expect(command.moderationNotes).toBeUndefined();
});
});
describe('UploadMediaCommand', () => {
it('should store all properties', () => {
const file = {
buffer: Buffer.from('test-image-data'),
mimetype: 'image/jpeg',
originalname: 'photo.jpg',
size: 1024,
};
const command = new UploadMediaCommand(
'prop-1',
'user-1',
file,
'Mặt tiền nhà',
);
expect(command.propertyId).toBe('prop-1');
expect(command.userId).toBe('user-1');
expect(command.file.mimetype).toBe('image/jpeg');
expect(command.file.originalname).toBe('photo.jpg');
expect(command.file.size).toBe(1024);
expect(command.caption).toBe('Mặt tiền nhà');
});
it('should default caption to undefined', () => {
const file = {
buffer: Buffer.from('test-video-data'),
mimetype: 'video/mp4',
originalname: 'tour.mp4',
size: 50_000_000,
};
const command = new UploadMediaCommand('prop-2', 'user-2', file);
expect(command.caption).toBeUndefined();
});
});

View File

@@ -0,0 +1,103 @@
import { describe, it, expect } from 'vitest';
import { GetListingQuery } from '../queries/get-listing/get-listing.query';
import { GetPendingModerationQuery } from '../queries/get-pending-moderation/get-pending-moderation.query';
import { SearchListingsQuery } from '../queries/search-listings/search-listings.query';
describe('GetListingQuery', () => {
it('should store listingId', () => {
const query = new GetListingQuery('listing-1');
expect(query.listingId).toBe('listing-1');
});
it('should store different listing IDs', () => {
const query1 = new GetListingQuery('a1b2c3d4');
const query2 = new GetListingQuery('e5f6g7h8');
expect(query1.listingId).toBe('a1b2c3d4');
expect(query2.listingId).toBe('e5f6g7h8');
expect(query1.listingId).not.toBe(query2.listingId);
});
});
describe('GetPendingModerationQuery', () => {
it('should use default page and limit', () => {
const query = new GetPendingModerationQuery();
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
});
it('should accept custom page and limit', () => {
const query = new GetPendingModerationQuery(3, 50);
expect(query.page).toBe(3);
expect(query.limit).toBe(50);
});
it('should accept custom page with default limit', () => {
const query = new GetPendingModerationQuery(2);
expect(query.page).toBe(2);
expect(query.limit).toBe(20);
});
});
describe('SearchListingsQuery', () => {
it('should use defaults when no params provided', () => {
const query = new SearchListingsQuery();
expect(query.status).toBeUndefined();
expect(query.transactionType).toBeUndefined();
expect(query.propertyType).toBeUndefined();
expect(query.city).toBeUndefined();
expect(query.district).toBeUndefined();
expect(query.minPrice).toBeUndefined();
expect(query.maxPrice).toBeUndefined();
expect(query.minArea).toBeUndefined();
expect(query.maxArea).toBeUndefined();
expect(query.bedrooms).toBeUndefined();
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
});
it('should store all filter parameters', () => {
const query = new SearchListingsQuery(
'ACTIVE',
'SALE',
'APARTMENT',
'Hồ Chí Minh',
'Quận 1',
2_000_000_000n,
10_000_000_000n,
50,
200,
2,
1,
20,
);
expect(query.status).toBe('ACTIVE');
expect(query.transactionType).toBe('SALE');
expect(query.propertyType).toBe('APARTMENT');
expect(query.city).toBe('Hồ Chí Minh');
expect(query.district).toBe('Quận 1');
expect(query.minPrice).toBe(2_000_000_000n);
expect(query.maxPrice).toBe(10_000_000_000n);
expect(query.minArea).toBe(50);
expect(query.maxArea).toBe(200);
expect(query.bedrooms).toBe(2);
expect(query.page).toBe(1);
expect(query.limit).toBe(20);
});
it('should allow partial filter parameters', () => {
const query = new SearchListingsQuery(
'ACTIVE',
undefined,
'VILLA',
'Hồ Chí Minh',
);
expect(query.status).toBe('ACTIVE');
expect(query.transactionType).toBeUndefined();
expect(query.propertyType).toBe('VILLA');
expect(query.city).toBe('Hồ Chí Minh');
expect(query.district).toBeUndefined();
});
});

View File

@@ -0,0 +1,131 @@
import { describe, it, expect } from 'vitest';
import { ListingEntity } from '../entities/listing.entity';
import { ModerationService } from '../services/moderation.service';
import { Price } from '../value-objects/price.vo';
describe('ModerationService', () => {
const service = new ModerationService();
const makeListingInReview = () => {
const price = Price.create(5_000_000_000n).unwrap();
const listing = ListingEntity.createNew(
'listing-1',
'property-1',
'seller-1',
'SALE',
price,
100,
'agent-1',
);
listing.submitForReview();
listing.clearDomainEvents();
return listing;
};
describe('applyModeration', () => {
it('should approve a listing', () => {
const listing = makeListingInReview();
service.applyModeration(listing, { action: 'approve' });
expect(listing.status).toBe('ACTIVE');
expect(listing.publishedAt).toBeTruthy();
});
it('should reject a listing with default notes', () => {
const listing = makeListingInReview();
service.applyModeration(listing, { action: 'reject' });
expect(listing.status).toBe('REJECTED');
expect(listing.moderationNotes).toBe('Bị từ chối bởi moderator');
});
it('should reject a listing with custom notes', () => {
const listing = makeListingInReview();
service.applyModeration(listing, {
action: 'reject',
notes: 'Ảnh không rõ ràng',
});
expect(listing.status).toBe('REJECTED');
expect(listing.moderationNotes).toBe('Ảnh không rõ ràng');
});
it('should set moderation score when provided on approve', () => {
const listing = makeListingInReview();
service.applyModeration(listing, {
action: 'approve',
moderationScore: 95,
notes: 'Tin đăng chất lượng',
});
expect(listing.status).toBe('ACTIVE');
expect(listing.moderationScore).toBe(95);
expect(listing.moderationNotes).toBe('Tin đăng chất lượng');
});
it('should set moderation score when provided on reject', () => {
const listing = makeListingInReview();
service.applyModeration(listing, {
action: 'reject',
moderationScore: 20,
notes: 'Nội dung vi phạm',
});
expect(listing.status).toBe('REJECTED');
expect(listing.moderationScore).toBe(20);
});
});
describe('applyStatusTransition', () => {
it('should reject with moderation notes', () => {
const listing = makeListingInReview();
service.applyStatusTransition(listing, 'REJECTED', 'Vi phạm chính sách');
expect(listing.status).toBe('REJECTED');
expect(listing.moderationNotes).toBe('Vi phạm chính sách');
});
it('should approve a PENDING_REVIEW listing transitioning to ACTIVE', () => {
const listing = makeListingInReview();
service.applyStatusTransition(listing, 'ACTIVE');
expect(listing.status).toBe('ACTIVE');
expect(listing.publishedAt).toBeTruthy();
});
it('should use generic transitionTo for non-moderation transitions', () => {
const price = Price.create(5_000_000_000n).unwrap();
const listing = ListingEntity.createNew(
'listing-2',
'property-2',
'seller-2',
'SALE',
price,
100,
);
listing.submitForReview();
listing.approve();
listing.clearDomainEvents();
service.applyStatusTransition(listing, 'SOLD');
expect(listing.status).toBe('SOLD');
});
it('should use generic transitionTo for REJECTED without notes', () => {
const listing = makeListingInReview();
// REJECTED without moderationNotes falls into the generic transitionTo branch
service.applyStatusTransition(listing, 'REJECTED');
expect(listing.status).toBe('REJECTED');
});
});
});

View File

@@ -0,0 +1,83 @@
import { describe, it, expect } from 'vitest';
import { PropertyMediaEntity } from '../entities/property-media.entity';
describe('PropertyMediaEntity', () => {
it('should create media via constructor with all properties', () => {
const media = new PropertyMediaEntity('media-1', {
propertyId: 'prop-1',
url: 'https://cdn.example.com/photo.jpg',
type: 'image',
order: 0,
caption: 'Mặt tiền nhà',
aiTags: { tags: ['exterior'] },
});
expect(media.id).toBe('media-1');
expect(media.propertyId).toBe('prop-1');
expect(media.url).toBe('https://cdn.example.com/photo.jpg');
expect(media.type).toBe('image');
expect(media.order).toBe(0);
expect(media.caption).toBe('Mặt tiền nhà');
expect(media.aiTags).toEqual({ tags: ['exterior'] });
expect(media.createdAt).toBeInstanceOf(Date);
});
it('should create media via createNew factory with caption', () => {
const media = PropertyMediaEntity.createNew(
'media-2',
'prop-1',
'https://cdn.example.com/interior.jpg',
'image',
1,
'Phòng khách',
);
expect(media.id).toBe('media-2');
expect(media.propertyId).toBe('prop-1');
expect(media.url).toBe('https://cdn.example.com/interior.jpg');
expect(media.type).toBe('image');
expect(media.order).toBe(1);
expect(media.caption).toBe('Phòng khách');
expect(media.aiTags).toBeNull();
});
it('should create media via createNew factory without caption', () => {
const media = PropertyMediaEntity.createNew(
'media-3',
'prop-2',
'https://cdn.example.com/video.mp4',
'video',
0,
);
expect(media.type).toBe('video');
expect(media.caption).toBeNull();
expect(media.aiTags).toBeNull();
});
it('should preserve order values', () => {
const media0 = PropertyMediaEntity.createNew('m-0', 'p-1', 'http://a.com/0.jpg', 'image', 0);
const media5 = PropertyMediaEntity.createNew('m-5', 'p-1', 'http://a.com/5.jpg', 'image', 5);
expect(media0.order).toBe(0);
expect(media5.order).toBe(5);
});
it('should accept custom createdAt via constructor', () => {
const pastDate = new Date('2024-01-01T00:00:00Z');
const media = new PropertyMediaEntity(
'media-old',
{
propertyId: 'prop-1',
url: 'https://cdn.example.com/old.jpg',
type: 'image',
order: 0,
caption: null,
aiTags: null,
},
pastDate,
);
expect(media.createdAt).toBe(pastDate);
});
});

View File

@@ -0,0 +1,198 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { findByIdWithProperty, searchListings, findBySellerIdQuery } from '../repositories/listing-read.queries';
describe('listing-read.queries', () => {
let mockPrisma: {
listing: {
findUnique: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
mockPrisma = {
listing: {
findUnique: vi.fn(),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
});
describe('findByIdWithProperty', () => {
it('should return null when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const result = await findByIdWithProperty(mockPrisma as any, 'non-existent');
expect(result).toBeNull();
});
it('should return mapped ListingDetailData when listing is found', async () => {
const now = new Date();
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: 5_000_000_000n,
pricePerM2: 62_500_000,
rentPriceMonthly: null,
commissionPct: 2.0,
viewCount: 10,
saveCount: 2,
inquiryCount: 1,
publishedAt: now,
createdAt: now,
property: {
id: 'prop-1',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp',
description: 'Mô tả',
address: '123 Nguyễn Huệ',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
areaM2: 80,
bedrooms: 2,
bathrooms: 2,
floors: null,
direction: 'EAST',
yearBuilt: 2022,
legalStatus: 'Sổ hồng',
amenities: null,
projectName: 'Vinhomes',
media: [
{ id: 'm-1', url: 'https://cdn.example.com/1.jpg', type: 'image', order: 0, caption: null },
],
},
seller: { id: 'seller-1', fullName: 'Nguyễn Văn A', phone: '0901234567' },
agent: { id: 'agent-1', userId: 'user-a', agency: 'Đất Xanh' },
});
const result = await findByIdWithProperty(mockPrisma as any, 'listing-1');
expect(result).not.toBeNull();
expect(result!.id).toBe('listing-1');
expect(result!.status).toBe('ACTIVE');
expect(result!.priceVND).toBe('5000000000');
expect(result!.property.title).toBe('Căn hộ đẹp');
expect(result!.property.media).toHaveLength(1);
expect(result!.seller.fullName).toBe('Nguyễn Văn A');
expect(result!.agent!.agency).toBe('Đất Xanh');
expect(result!.publishedAt).toBe(now.toISOString());
});
});
describe('searchListings', () => {
it('should return paginated results with empty data', async () => {
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.listing.count.mockResolvedValue(0);
const result = await searchListings(mockPrisma as any, { page: 1, limit: 20 });
expect(result.data).toEqual([]);
expect(result.total).toBe(0);
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
expect(result.totalPages).toBe(0);
});
it('should map listing data correctly', async () => {
const now = new Date();
mockPrisma.listing.findMany.mockResolvedValue([
{
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: 5_000_000_000n,
pricePerM2: 62_500_000,
viewCount: 5,
publishedAt: now,
property: {
id: 'prop-1',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp',
address: '123 Nguyễn Huệ',
district: 'Quận 1',
city: 'Hồ Chí Minh',
areaM2: 80,
bedrooms: 2,
bathrooms: 2,
media: [{ url: 'https://cdn.example.com/thumb.jpg' }],
},
seller: { id: 'seller-1', fullName: 'Nguyễn Văn A' },
},
]);
mockPrisma.listing.count.mockResolvedValue(1);
const result = await searchListings(mockPrisma as any, { status: 'ACTIVE', page: 1, limit: 20 });
expect(result.data).toHaveLength(1);
expect(result.data[0]!.id).toBe('listing-1');
expect(result.data[0]!.priceVND).toBe('5000000000');
expect(result.data[0]!.property.thumbnail).toBe('https://cdn.example.com/thumb.jpg');
expect(result.total).toBe(1);
expect(result.totalPages).toBe(1);
});
it('should cap limit at 100', async () => {
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.listing.count.mockResolvedValue(0);
const result = await searchListings(mockPrisma as any, { limit: 500 });
expect(result.limit).toBe(100);
});
it('should default page to 1 and limit to 20', async () => {
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.listing.count.mockResolvedValue(0);
const result = await searchListings(mockPrisma as any, {});
expect(result.page).toBe(1);
expect(result.limit).toBe(20);
});
});
describe('findBySellerIdQuery', () => {
it('should return paginated seller listings', async () => {
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.listing.count.mockResolvedValue(0);
const result = await findBySellerIdQuery(mockPrisma as any, 'seller-1', 1, 10);
expect(result.data).toEqual([]);
expect(result.total).toBe(0);
expect(result.page).toBe(1);
expect(result.limit).toBe(10);
});
it('should map seller listing data correctly', async () => {
mockPrisma.listing.findMany.mockResolvedValue([
{
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: 3_000_000_000n,
property: {
id: 'prop-1',
title: 'Nhà phố đẹp',
district: 'Quận 3',
city: 'Hồ Chí Minh',
areaM2: 120,
media: [{ url: 'https://cdn.example.com/thumb.jpg' }],
},
},
]);
mockPrisma.listing.count.mockResolvedValue(1);
const result = await findBySellerIdQuery(mockPrisma as any, 'seller-1', 1, 10);
expect(result.data).toHaveLength(1);
expect(result.data[0]!.priceVND).toBe('3000000000');
expect(result.data[0]!.property.thumbnail).toBe('https://cdn.example.com/thumb.jpg');
});
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PrismaDuplicateDetector } from '../services/prisma-duplicate-detector';
describe('PrismaDuplicateDetector', () => {
let detector: PrismaDuplicateDetector;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn().mockResolvedValue([]),
};
detector = new PrismaDuplicateDetector(mockPrisma as any);
});
it('should return empty array when no nearby properties found', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await detector.findDuplicates({
excludePropertyId: 'prop-new',
latitude: 10.7769,
longitude: 106.7009,
title: 'Căn hộ đẹp Quận 1',
propertyType: 'APARTMENT',
});
expect(result).toEqual([]);
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
});
it('should return duplicates when nearby properties with similar titles exist', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{
listing_id: 'listing-dup',
property_id: 'prop-dup',
title: 'Căn hộ đẹp Quận 1',
address: '123 Nguyễn Huệ',
district: 'Quận 1',
property_type: 'APARTMENT',
distance_meters: 50,
},
]);
const result = await detector.findDuplicates({
excludePropertyId: 'prop-new',
latitude: 10.7769,
longitude: 106.7009,
title: 'Căn hộ đẹp Quận 1',
propertyType: 'APARTMENT',
});
expect(result).toHaveLength(1);
expect(result[0]!.listingId).toBe('listing-dup');
expect(result[0]!.propertyId).toBe('prop-dup');
expect(result[0]!.distanceMeters).toBe(50);
expect(result[0]!.titleSimilarity).toBe(1); // exact match
});
it('should filter out low-similarity results', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{
listing_id: 'listing-diff',
property_id: 'prop-diff',
title: 'Biệt thự sang trọng Thảo Điền',
address: '456 Quốc Hương',
district: 'Quận 2',
property_type: 'APARTMENT',
distance_meters: 80,
},
]);
const result = await detector.findDuplicates({
excludePropertyId: 'prop-new',
latitude: 10.7769,
longitude: 106.7009,
title: 'Căn hộ đẹp Quận 1',
propertyType: 'APARTMENT',
});
// Titles are very different, so similarity should be below threshold
expect(result).toHaveLength(0);
});
it('should use custom radius and similarity threshold', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
await detector.findDuplicates({
excludePropertyId: 'prop-new',
latitude: 10.7769,
longitude: 106.7009,
title: 'Test listing',
propertyType: 'TOWNHOUSE',
radiusMeters: 200,
minTitleSimilarity: 0.9,
});
expect(mockPrisma.$queryRaw).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,141 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ListingEntity } from '../../domain/entities/listing.entity';
import { Price } from '../../domain/value-objects/price.vo';
import { PrismaListingRepository } from '../repositories/prisma-listing.repository';
describe('PrismaListingRepository', () => {
let repository: PrismaListingRepository;
let mockPrisma: {
listing: {
findUnique: ReturnType<typeof vi.fn>;
create: ReturnType<typeof vi.fn>;
update: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
};
beforeEach(() => {
mockPrisma = {
listing: {
findUnique: vi.fn(),
create: vi.fn().mockResolvedValue(undefined),
update: vi.fn().mockResolvedValue(undefined),
findMany: vi.fn().mockResolvedValue([]),
count: vi.fn().mockResolvedValue(0),
},
};
repository = new PrismaListingRepository(mockPrisma as any);
});
describe('findById', () => {
it('should return null when listing not found', async () => {
mockPrisma.listing.findUnique.mockResolvedValue(null);
const result = await repository.findById('non-existent');
expect(result).toBeNull();
expect(mockPrisma.listing.findUnique).toHaveBeenCalledWith({
where: { id: 'non-existent' },
});
});
it('should return a ListingEntity when listing is found', async () => {
const now = new Date();
mockPrisma.listing.findUnique.mockResolvedValue({
id: 'listing-1',
propertyId: 'prop-1',
agentId: 'agent-1',
sellerId: 'seller-1',
transactionType: 'SALE',
status: 'ACTIVE',
priceVND: 5_000_000_000n,
pricePerM2: 62_500_000,
rentPriceMonthly: null,
commissionPct: 2.0,
aiPriceEstimate: null,
aiConfidence: null,
moderationScore: null,
moderationNotes: null,
viewCount: 10,
saveCount: 2,
inquiryCount: 1,
featuredUntil: null,
expiresAt: null,
publishedAt: now,
createdAt: now,
updatedAt: now,
});
const result = await repository.findById('listing-1');
expect(result).toBeInstanceOf(ListingEntity);
expect(result!.id).toBe('listing-1');
expect(result!.propertyId).toBe('prop-1');
expect(result!.status).toBe('ACTIVE');
expect(result!.viewCount).toBe(10);
});
});
describe('save', () => {
it('should call prisma.listing.create with correct data', async () => {
const price = Price.create(5_000_000_000n).unwrap();
const listing = ListingEntity.createNew(
'listing-new',
'prop-new',
'seller-1',
'SALE',
price,
100,
'agent-1',
);
await repository.save(listing);
expect(mockPrisma.listing.create).toHaveBeenCalledTimes(1);
const createCall = mockPrisma.listing.create.mock.calls[0]![0];
expect(createCall.data.id).toBe('listing-new');
expect(createCall.data.propertyId).toBe('prop-new');
expect(createCall.data.sellerId).toBe('seller-1');
expect(createCall.data.status).toBe('DRAFT');
});
});
describe('update', () => {
it('should call prisma.listing.update with correct data', async () => {
const price = Price.create(5_000_000_000n).unwrap();
const listing = ListingEntity.createNew(
'listing-upd',
'prop-upd',
'seller-1',
'SALE',
price,
80,
);
await repository.update(listing);
expect(mockPrisma.listing.update).toHaveBeenCalledTimes(1);
const updateCall = mockPrisma.listing.update.mock.calls[0]![0];
expect(updateCall.where.id).toBe('listing-upd');
expect(updateCall.data.status).toBe('DRAFT');
});
});
describe('findByStatus', () => {
it('should delegate to search with status filter', async () => {
mockPrisma.listing.findMany.mockResolvedValue([]);
mockPrisma.listing.count.mockResolvedValue(0);
const result = await repository.findByStatus('PENDING_REVIEW', 1, 20);
expect(result).toEqual({
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
});
});
});
});

View File

@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PrismaPriceValidator } from '../services/prisma-price-validator';
describe('PrismaPriceValidator', () => {
let validator: PrismaPriceValidator;
let mockPrisma: { $queryRaw: ReturnType<typeof vi.fn> };
let mockLogger: { log: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; error: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockPrisma = {
$queryRaw: vi.fn().mockResolvedValue([]),
};
mockLogger = {
log: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
validator = new PrismaPriceValidator(mockPrisma as any, mockLogger as any);
});
it('should return valid for normal price within default range', async () => {
// No market data — falls back to defaults
mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
// 5B / 80 = 62.5M per m2; APARTMENT default range: 15M-200M
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(false);
expect(result.pricePerM2).toBe(62_500_000);
});
it('should return invalid for zero area', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 0,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
expect(result.isValid).toBe(false);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('không hợp lệ');
});
it('should flag suspiciously low price', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
// 100M / 100 = 1M per m2 — way below APARTMENT min of 15M * 0.5 = 7.5M
const result = await validator.validate({
priceVND: 100_000_000n,
areaM2: 100,
propertyType: 'APARTMENT',
district: 'Quận 7',
});
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('thấp hơn');
});
it('should flag suspiciously high price', async () => {
mockPrisma.$queryRaw.mockResolvedValue([]);
// 100B / 100 = 1B per m2 — above APARTMENT max of 200M * 1.5 = 300M
const result = await validator.validate({
priceVND: 100_000_000_000n,
areaM2: 100,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(true);
expect(result.reason).toContain('cao hơn');
});
it('should use market data when available', async () => {
mockPrisma.$queryRaw.mockResolvedValue([
{ min_price: 50_000_000, max_price: 100_000_000 },
]);
const result = await validator.validate({
priceVND: 6_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
// 6B / 80 = 75M per m2; market range: 50M-100M — within range
expect(result.isValid).toBe(true);
expect(result.isSuspicious).toBe(false);
expect(result.minPricePerM2).toBe(50_000_000);
expect(result.maxPricePerM2).toBe(100_000_000);
});
it('should fall back to defaults when market query fails', async () => {
mockPrisma.$queryRaw.mockRejectedValue(new Error('DB error'));
const result = await validator.validate({
priceVND: 5_000_000_000n,
areaM2: 80,
propertyType: 'APARTMENT',
district: 'Quận 1',
});
expect(result.isValid).toBe(true);
expect(mockLogger.warn).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,169 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { PropertyMediaEntity } from '../../domain/entities/property-media.entity';
import { PropertyEntity } from '../../domain/entities/property.entity';
import { PrismaPropertyRepository } from '../repositories/prisma-property.repository';
describe('PrismaPropertyRepository', () => {
let repository: PrismaPropertyRepository;
let mockPrisma: {
property: {
findUnique: ReturnType<typeof vi.fn>;
};
propertyMedia: {
create: ReturnType<typeof vi.fn>;
findMany: ReturnType<typeof vi.fn>;
delete: ReturnType<typeof vi.fn>;
count: ReturnType<typeof vi.fn>;
};
$executeRaw: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
mockPrisma = {
property: {
findUnique: vi.fn(),
},
propertyMedia: {
create: vi.fn().mockResolvedValue(undefined),
findMany: vi.fn().mockResolvedValue([]),
delete: vi.fn().mockResolvedValue(undefined),
count: vi.fn().mockResolvedValue(0),
},
$executeRaw: vi.fn().mockResolvedValue(1),
};
repository = new PrismaPropertyRepository(mockPrisma as any);
});
describe('findById', () => {
it('should return null when property not found', async () => {
mockPrisma.property.findUnique.mockResolvedValue(null);
const result = await repository.findById('non-existent');
expect(result).toBeNull();
expect(mockPrisma.property.findUnique).toHaveBeenCalledWith({
where: { id: 'non-existent' },
});
});
it('should return a PropertyEntity when property is found', async () => {
const now = new Date();
mockPrisma.property.findUnique.mockResolvedValue({
id: 'prop-1',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp',
description: 'Mô tả chi tiết',
address: '123 Nguyễn Huệ',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
location: null, // PostGIS geometry placeholder
areaM2: 80,
usableAreaM2: 70,
bedrooms: 2,
bathrooms: 2,
floors: null,
floor: 10,
totalFloors: 25,
direction: 'EAST',
yearBuilt: 2022,
legalStatus: 'Sổ hồng',
amenities: null,
nearbyPOIs: null,
metroDistanceM: 300,
projectName: 'Vinhomes',
createdAt: now,
updatedAt: now,
});
const result = await repository.findById('prop-1');
expect(result).toBeInstanceOf(PropertyEntity);
expect(result!.id).toBe('prop-1');
expect(result!.title).toBe('Căn hộ đẹp');
expect(result!.propertyType).toBe('APARTMENT');
});
});
describe('addMedia', () => {
it('should call prisma.propertyMedia.create with correct data', async () => {
const media = PropertyMediaEntity.createNew(
'media-1',
'prop-1',
'https://cdn.example.com/photo.jpg',
'image',
0,
'Mặt tiền',
);
await repository.addMedia(media);
expect(mockPrisma.propertyMedia.create).toHaveBeenCalledTimes(1);
const createCall = mockPrisma.propertyMedia.create.mock.calls[0]![0];
expect(createCall.data.id).toBe('media-1');
expect(createCall.data.propertyId).toBe('prop-1');
expect(createCall.data.url).toBe('https://cdn.example.com/photo.jpg');
expect(createCall.data.type).toBe('image');
expect(createCall.data.order).toBe(0);
expect(createCall.data.caption).toBe('Mặt tiền');
});
});
describe('findMediaByPropertyId', () => {
it('should return mapped PropertyMediaEntity array', async () => {
const now = new Date();
mockPrisma.propertyMedia.findMany.mockResolvedValue([
{
id: 'media-1',
propertyId: 'prop-1',
url: 'https://cdn.example.com/1.jpg',
type: 'image',
order: 0,
caption: null,
aiTags: null,
createdAt: now,
},
{
id: 'media-2',
propertyId: 'prop-1',
url: 'https://cdn.example.com/2.jpg',
type: 'image',
order: 1,
caption: 'Phòng ngủ',
aiTags: null,
createdAt: now,
},
]);
const result = await repository.findMediaByPropertyId('prop-1');
expect(result).toHaveLength(2);
expect(result[0]).toBeInstanceOf(PropertyMediaEntity);
expect(result[0]!.id).toBe('media-1');
expect(result[1]!.caption).toBe('Phòng ngủ');
});
});
describe('deleteMedia', () => {
it('should call prisma.propertyMedia.delete', async () => {
await repository.deleteMedia('media-1');
expect(mockPrisma.propertyMedia.delete).toHaveBeenCalledWith({
where: { id: 'media-1' },
});
});
});
describe('countMediaByPropertyId', () => {
it('should return count from prisma', async () => {
mockPrisma.propertyMedia.count.mockResolvedValue(5);
const result = await repository.countMediaByPropertyId('prop-1');
expect(result).toBe(5);
expect(mockPrisma.propertyMedia.count).toHaveBeenCalledWith({
where: { propertyId: 'prop-1' },
});
});
});
});

View File

@@ -0,0 +1,140 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, it, expect } from 'vitest';
import { CreateListingDto } from '../dto/create-listing.dto';
describe('CreateListingDto', () => {
it('should pass validation with all required fields', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'SALE',
priceVND: '5500000000',
propertyType: 'APARTMENT',
title: 'Căn hộ 3PN view sông Sài Gòn',
description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ',
address: '208 Nguyễn Hữu Cảnh',
ward: 'Phường 22',
district: 'Bình Thạnh',
city: 'Hồ Chí Minh',
latitude: 10.7942,
longitude: 106.7219,
areaM2: 85.5,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should fail validation when title is too short', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'SALE',
priceVND: '5000000000',
propertyType: 'APARTMENT',
title: 'AB',
description: 'Căn hộ cao cấp 3 phòng ngủ, nội thất đầy đủ',
address: '123 ABC',
ward: 'Ward',
district: 'District',
city: 'City',
latitude: 10.77,
longitude: 106.70,
areaM2: 80,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const titleError = errors.find((e) => e.property === 'title');
expect(titleError).toBeDefined();
});
it('should fail validation with invalid transactionType', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'INVALID',
priceVND: '5000000000',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp Quận 1',
description: 'Mô tả đầy đủ chi tiết',
address: '123 ABC',
ward: 'Ward',
district: 'District',
city: 'City',
latitude: 10.77,
longitude: 106.70,
areaM2: 80,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const typeError = errors.find((e) => e.property === 'transactionType');
expect(typeError).toBeDefined();
});
it('should fail validation when latitude is out of range', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'SALE',
priceVND: '5000000000',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp Quận 1',
description: 'Mô tả đầy đủ chi tiết',
address: '123 ABC',
ward: 'Ward',
district: 'District',
city: 'City',
latitude: 999,
longitude: 106.70,
areaM2: 80,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const latError = errors.find((e) => e.property === 'latitude');
expect(latError).toBeDefined();
});
it('should pass validation with optional fields', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'SALE',
priceVND: '5500000000',
propertyType: 'TOWNHOUSE',
title: 'Nhà phố đẹp Quận 3',
description: 'Nhà phố 3 tầng mặt tiền rộng',
address: '456 Lê Lợi',
ward: 'Phường 1',
district: 'Quận 3',
city: 'Hồ Chí Minh',
latitude: 10.78,
longitude: 106.69,
areaM2: 120,
bedrooms: 3,
bathrooms: 2,
floors: 3,
direction: 'EAST',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should fail validation when areaM2 is less than 1', async () => {
const dto = plainToInstance(CreateListingDto, {
transactionType: 'SALE',
priceVND: '5000000000',
propertyType: 'APARTMENT',
title: 'Căn hộ đẹp Quận 1',
description: 'Mô tả đầy đủ chi tiết',
address: '123 ABC',
ward: 'Ward',
district: 'District',
city: 'City',
latitude: 10.77,
longitude: 106.70,
areaM2: 0,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const areaError = errors.find((e) => e.property === 'areaM2');
expect(areaError).toBeDefined();
});
});

View File

@@ -0,0 +1,132 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { ListingsController } from '../controllers/listings.controller';
describe('ListingsController', () => {
let controller: ListingsController;
let mockCommandBus: { execute: ReturnType<typeof vi.fn> };
let mockQueryBus: { execute: ReturnType<typeof vi.fn> };
beforeEach(() => {
mockCommandBus = { execute: vi.fn() };
mockQueryBus = { execute: vi.fn() };
controller = new ListingsController(mockCommandBus as any, mockQueryBus as any);
});
describe('createListing', () => {
it('should execute CreateListingCommand via command bus', async () => {
const mockResult = {
listingId: 'listing-1',
propertyId: 'prop-1',
status: 'DRAFT',
duplicateWarnings: [],
};
mockCommandBus.execute.mockResolvedValue(mockResult);
const dto = {
transactionType: 'SALE' as const,
priceVND: 5_000_000_000n,
propertyType: 'APARTMENT' as const,
title: 'Căn hộ đẹp Quận 1',
description: 'Mô tả chi tiết căn hộ',
address: '123 Nguyễn Huệ',
ward: 'Bến Nghé',
district: 'Quận 1',
city: 'Hồ Chí Minh',
latitude: 10.7769,
longitude: 106.7009,
areaM2: 80,
};
const user = { sub: 'seller-1', email: 'test@example.com', role: 'SELLER' };
const result = await controller.createListing(dto as any, user as any);
expect(result).toEqual(mockResult);
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('getListing', () => {
it('should execute GetListingQuery via query bus', async () => {
const mockDetail = {
id: 'listing-1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '5000000000',
};
mockQueryBus.execute.mockResolvedValue(mockDetail);
const result = await controller.getListing('listing-1');
expect(result).toEqual(mockDetail);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('searchListings', () => {
it('should execute SearchListingsQuery via query bus', async () => {
const mockResults = {
data: [],
total: 0,
page: 1,
limit: 20,
totalPages: 0,
};
mockQueryBus.execute.mockResolvedValue(mockResults);
const dto = { status: 'ACTIVE' as const, page: 1, limit: 20 };
const result = await controller.searchListings(dto as any);
expect(result).toEqual(mockResults);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('updateStatus', () => {
it('should execute UpdateListingStatusCommand via command bus', async () => {
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });
const dto = { status: 'ACTIVE' as const };
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
const result = await controller.updateStatus('listing-1', dto as any, user as any);
expect(result).toEqual({ status: 'ACTIVE' });
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});
describe('getPendingModeration', () => {
it('should execute GetPendingModerationQuery with defaults', async () => {
const mockResults = { data: [], total: 0, page: 1, limit: 20, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(mockResults);
const result = await controller.getPendingModeration();
expect(result).toEqual(mockResults);
expect(mockQueryBus.execute).toHaveBeenCalledTimes(1);
});
it('should execute GetPendingModerationQuery with custom page/limit', async () => {
const mockResults = { data: [], total: 0, page: 2, limit: 10, totalPages: 0 };
mockQueryBus.execute.mockResolvedValue(mockResults);
const result = await controller.getPendingModeration(2, 10);
expect(result).toEqual(mockResults);
});
});
describe('moderateListing', () => {
it('should execute ModerateListingCommand via command bus', async () => {
mockCommandBus.execute.mockResolvedValue({ status: 'ACTIVE' });
const dto = { action: 'approve' as const, moderationScore: 90, notes: 'Hợp lệ' };
const user = { sub: 'admin-1', email: 'admin@example.com', role: 'ADMIN' };
const result = await controller.moderateListing('listing-1', dto as any, user as any);
expect(result).toEqual({ status: 'ACTIVE' });
expect(mockCommandBus.execute).toHaveBeenCalledTimes(1);
});
});
});

View File

@@ -0,0 +1,76 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, it, expect } from 'vitest';
import { ModerateListingDto } from '../dto/moderate-listing.dto';
describe('ModerateListingDto', () => {
it('should pass validation with approve action', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'approve',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.action).toBe('approve');
});
it('should pass validation with reject action and notes', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'reject',
notes: 'Ảnh không rõ ràng',
moderationScore: 30,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.action).toBe('reject');
expect(dto.notes).toBe('Ảnh không rõ ràng');
expect(dto.moderationScore).toBe(30);
});
it('should fail validation with invalid action', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'suspend',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const actionError = errors.find((e) => e.property === 'action');
expect(actionError).toBeDefined();
});
it('should fail validation when moderationScore exceeds 100', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'approve',
moderationScore: 150,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const scoreError = errors.find((e) => e.property === 'moderationScore');
expect(scoreError).toBeDefined();
});
it('should fail validation when moderationScore is negative', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'approve',
moderationScore: -10,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const scoreError = errors.find((e) => e.property === 'moderationScore');
expect(scoreError).toBeDefined();
});
it('should pass validation with optional fields omitted', async () => {
const dto = plainToInstance(ModerateListingDto, {
action: 'reject',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.moderationScore).toBeUndefined();
expect(dto.notes).toBeUndefined();
});
});

View File

@@ -0,0 +1,77 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, it, expect } from 'vitest';
import { SearchListingsDto } from '../dto/search-listings.dto';
describe('SearchListingsDto', () => {
it('should pass validation with no filters (all optional)', async () => {
const dto = plainToInstance(SearchListingsDto, {});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
});
it('should pass validation with valid status and filters', async () => {
const dto = plainToInstance(SearchListingsDto, {
status: 'ACTIVE',
transactionType: 'SALE',
propertyType: 'APARTMENT',
city: 'Hồ Chí Minh',
district: 'Quận 1',
page: 1,
limit: 20,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.status).toBe('ACTIVE');
expect(dto.transactionType).toBe('SALE');
});
it('should fail validation with invalid status enum', async () => {
const dto = plainToInstance(SearchListingsDto, {
status: 'INVALID_STATUS',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const statusError = errors.find((e) => e.property === 'status');
expect(statusError).toBeDefined();
});
it('should fail validation when limit exceeds 100', async () => {
const dto = plainToInstance(SearchListingsDto, {
limit: 200,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const limitError = errors.find((e) => e.property === 'limit');
expect(limitError).toBeDefined();
});
it('should fail validation when page is less than 1', async () => {
const dto = plainToInstance(SearchListingsDto, {
page: 0,
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const pageError = errors.find((e) => e.property === 'page');
expect(pageError).toBeDefined();
});
it('should pass validation with area and bedroom filters', async () => {
const dto = plainToInstance(SearchListingsDto, {
minArea: 50,
maxArea: 200,
bedrooms: 2,
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.minArea).toBe(50);
expect(dto.maxArea).toBe(200);
expect(dto.bedrooms).toBe(2);
});
});

View File

@@ -0,0 +1,58 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { describe, it, expect } from 'vitest';
import { UpdateListingStatusDto } from '../dto/update-listing-status.dto';
describe('UpdateListingStatusDto', () => {
it('should pass validation with valid status', async () => {
const dto = plainToInstance(UpdateListingStatusDto, {
status: 'ACTIVE',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.status).toBe('ACTIVE');
});
it('should pass validation with status and moderation notes', async () => {
const dto = plainToInstance(UpdateListingStatusDto, {
status: 'REJECTED',
moderationNotes: 'Đã xác minh thông tin pháp lý',
});
const errors = await validate(dto);
expect(errors).toHaveLength(0);
expect(dto.status).toBe('REJECTED');
expect(dto.moderationNotes).toBe('Đã xác minh thông tin pháp lý');
});
it('should fail validation with invalid status', async () => {
const dto = plainToInstance(UpdateListingStatusDto, {
status: 'INVALID_STATUS',
});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const statusError = errors.find((e) => e.property === 'status');
expect(statusError).toBeDefined();
});
it('should fail validation when status is missing', async () => {
const dto = plainToInstance(UpdateListingStatusDto, {});
const errors = await validate(dto);
expect(errors.length).toBeGreaterThan(0);
const statusError = errors.find((e) => e.property === 'status');
expect(statusError).toBeDefined();
});
it('should accept all valid ListingStatus enum values', async () => {
const validStatuses = ['DRAFT', 'PENDING_REVIEW', 'ACTIVE', 'RESERVED', 'SOLD', 'RENTED', 'EXPIRED', 'REJECTED'];
for (const status of validStatuses) {
const dto = plainToInstance(UpdateListingStatusDto, { status });
const errors = await validate(dto);
expect(errors).toHaveLength(0);
}
});
});