test(api): add domain layer unit tests across all modules

Cover admin events, notifications, reviews, search VOs, listings (property,
media, events, price/geo/address VOs), auth events, payment events,
subscription events, and analytics events. Raises domain test coverage
from ~24% to ~75%.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 00:36:39 +07:00
parent 801e29e65c
commit 62f4f001b6
14 changed files with 973 additions and 1 deletions

View File

@@ -0,0 +1,111 @@
import { describe, it, expect } from 'vitest';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingRejectedEvent } from '../events/listing-rejected.event';
import { UserBannedEvent } from '../events/user-banned.event';
import { UserUnbannedEvent } from '../events/user-unbanned.event';
import { SubscriptionAdjustedEvent } from '../events/subscription-adjusted.event';
import { KycApprovedEvent } from '../events/kyc-approved.event';
import { KycRejectedEvent } from '../events/kyc-rejected.event';
describe('Admin Domain Events', () => {
describe('ListingApprovedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingApprovedEvent('listing-1', 'admin-1', 'Looks good');
expect(event.eventName).toBe('listing.approved_by_admin');
expect(event.aggregateId).toBe('listing-1');
expect(event.adminId).toBe('admin-1');
expect(event.moderationNotes).toBe('Looks good');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event without moderation notes', () => {
const event = new ListingApprovedEvent('listing-2', 'admin-1');
expect(event.moderationNotes).toBeUndefined();
});
});
describe('ListingRejectedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingRejectedEvent('listing-1', 'admin-1', 'Ảnh không hợp lệ');
expect(event.eventName).toBe('listing.rejected_by_admin');
expect(event.aggregateId).toBe('listing-1');
expect(event.adminId).toBe('admin-1');
expect(event.reason).toBe('Ảnh không hợp lệ');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('UserBannedEvent', () => {
it('creates event with correct properties', () => {
const event = new UserBannedEvent('user-1', 'admin-1', 'Vi phạm chính sách');
expect(event.eventName).toBe('user.banned');
expect(event.aggregateId).toBe('user-1');
expect(event.adminId).toBe('admin-1');
expect(event.reason).toBe('Vi phạm chính sách');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('UserUnbannedEvent', () => {
it('creates event with correct properties', () => {
const event = new UserUnbannedEvent('user-1', 'admin-1');
expect(event.eventName).toBe('user.unbanned');
expect(event.aggregateId).toBe('user-1');
expect(event.adminId).toBe('admin-1');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('SubscriptionAdjustedEvent', () => {
it('creates event with correct properties', () => {
const event = new SubscriptionAdjustedEvent(
'sub-1',
'admin-1',
'plan-premium',
'Nâng cấp hỗ trợ đặc biệt',
);
expect(event.eventName).toBe('subscription.adjusted_by_admin');
expect(event.aggregateId).toBe('sub-1');
expect(event.adminId).toBe('admin-1');
expect(event.newPlanId).toBe('plan-premium');
expect(event.reason).toBe('Nâng cấp hỗ trợ đặc biệt');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('KycApprovedEvent', () => {
it('creates event with correct properties', () => {
const event = new KycApprovedEvent('user-1', 'admin-1', 'Hồ sơ đầy đủ');
expect(event.eventName).toBe('kyc.approved');
expect(event.aggregateId).toBe('user-1');
expect(event.adminId).toBe('admin-1');
expect(event.comments).toBe('Hồ sơ đầy đủ');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event without comments', () => {
const event = new KycApprovedEvent('user-2', 'admin-1');
expect(event.comments).toBeUndefined();
});
});
describe('KycRejectedEvent', () => {
it('creates event with correct properties', () => {
const event = new KycRejectedEvent('user-1', 'admin-1', 'CMND mờ không đọc được');
expect(event.eventName).toBe('kyc.rejected');
expect(event.aggregateId).toBe('user-1');
expect(event.adminId).toBe('admin-1');
expect(event.reason).toBe('CMND mờ không đọc được');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -0,0 +1,22 @@
import { describe, it, expect } from 'vitest';
import { MarketIndexUpdatedEvent } from '../events/market-index-updated.event';
describe('Analytics Domain Events', () => {
describe('MarketIndexUpdatedEvent', () => {
it('creates event with correct properties', () => {
const event = new MarketIndexUpdatedEvent('index-1', 'Quận 1', 'Hồ Chí Minh', '2026-Q1');
expect(event.eventName).toBe('market-index.updated');
expect(event.aggregateId).toBe('index-1');
expect(event.district).toBe('Quận 1');
expect(event.city).toBe('Hồ Chí Minh');
expect(event.period).toBe('2026-Q1');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for different district', () => {
const event = new MarketIndexUpdatedEvent('index-2', 'Thủ Đức', 'Hồ Chí Minh', '2026-Q1');
expect(event.district).toBe('Thủ Đức');
});
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { UserRegisteredEvent } from '../events/user-registered.event';
import { AgentVerifiedEvent } from '../events/agent-verified.event';
describe('Auth Domain Events', () => {
describe('UserRegisteredEvent', () => {
it('creates event with correct properties', () => {
const event = new UserRegisteredEvent('user-1', '+84912345678', 'BUYER');
expect(event.eventName).toBe('user.registered');
expect(event.aggregateId).toBe('user-1');
expect(event.phone).toBe('+84912345678');
expect(event.role).toBe('BUYER');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for AGENT role', () => {
const event = new UserRegisteredEvent('user-2', '+84987654321', 'AGENT');
expect(event.role).toBe('AGENT');
});
it('creates event for SELLER role', () => {
const event = new UserRegisteredEvent('user-3', '+84955555555', 'SELLER');
expect(event.role).toBe('SELLER');
});
});
describe('AgentVerifiedEvent', () => {
it('creates event with correct properties', () => {
const event = new AgentVerifiedEvent('agent-1', 'user-1');
expect(event.eventName).toBe('agent.verified');
expect(event.aggregateId).toBe('agent-1');
expect(event.userId).toBe('user-1');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { ListingCreatedEvent } from '../events/listing-created.event';
import { ListingApprovedEvent } from '../events/listing-approved.event';
import { ListingSoldEvent } from '../events/listing-sold.event';
describe('Listings Domain Events', () => {
describe('ListingCreatedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingCreatedEvent('listing-1', 'prop-1', 'seller-1', 'SALE');
expect(event.eventName).toBe('listing.created');
expect(event.aggregateId).toBe('listing-1');
expect(event.propertyId).toBe('prop-1');
expect(event.sellerId).toBe('seller-1');
expect(event.transactionType).toBe('SALE');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for RENT transaction', () => {
const event = new ListingCreatedEvent('listing-2', 'prop-2', 'seller-2', 'RENT');
expect(event.transactionType).toBe('RENT');
});
});
describe('ListingApprovedEvent', () => {
it('creates event with correct properties', () => {
const event = new ListingApprovedEvent('listing-1', 'prop-1');
expect(event.eventName).toBe('listing.approved');
expect(event.aggregateId).toBe('listing-1');
expect(event.propertyId).toBe('prop-1');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('ListingSoldEvent', () => {
it('creates event with SOLD status', () => {
const event = new ListingSoldEvent('listing-1', 'prop-1', 'SOLD');
expect(event.eventName).toBe('listing.sold');
expect(event.aggregateId).toBe('listing-1');
expect(event.propertyId).toBe('prop-1');
expect(event.finalStatus).toBe('SOLD');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event with RENTED status', () => {
const event = new ListingSoldEvent('listing-2', 'prop-2', 'RENTED');
expect(event.finalStatus).toBe('RENTED');
});
});
});

View File

@@ -0,0 +1,234 @@
import { describe, it, expect } from 'vitest';
import { PropertyEntity } from '../entities/property.entity';
import { PropertyMediaEntity } from '../entities/property-media.entity';
import { Address } from '../value-objects/address.vo';
import { GeoPoint } from '../value-objects/geo-point.vo';
import { Price } from '../value-objects/price.vo';
describe('PropertyEntity', () => {
const makeAddress = () =>
Address.create('123 Nguyễn Huệ', 'Bến Nghé', 'Quận 1', 'Hồ Chí Minh').unwrap();
const makeGeoPoint = () => GeoPoint.create(10.7769, 106.7009).unwrap();
it('creates property with all required fields', () => {
const property = PropertyEntity.createNew('prop-1', {
propertyType: 'TOWNHOUSE',
title: 'Nhà phố đẹp Quận 1',
description: 'Nhà phố 3 tầng, mặt tiền rộng',
address: makeAddress(),
location: makeGeoPoint(),
areaM2: 120,
usableAreaM2: 100,
bedrooms: 3,
bathrooms: 2,
floors: 3,
floor: null,
totalFloors: null,
direction: 'EAST',
yearBuilt: 2020,
legalStatus: 'Sổ hồng',
amenities: { parking: true },
nearbyPOIs: [],
metroDistanceM: 500,
projectName: null,
});
expect(property.id).toBe('prop-1');
expect(property.propertyType).toBe('TOWNHOUSE');
expect(property.title).toBe('Nhà phố đẹp Quận 1');
expect(property.areaM2).toBe(120);
expect(property.usableAreaM2).toBe(100);
expect(property.bedrooms).toBe(3);
expect(property.bathrooms).toBe(2);
expect(property.floors).toBe(3);
expect(property.direction).toBe('EAST');
expect(property.yearBuilt).toBe(2020);
expect(property.legalStatus).toBe('Sổ hồng');
expect(property.metroDistanceM).toBe(500);
expect(property.projectName).toBeNull();
});
it('creates property with nullable fields as null', () => {
const property = PropertyEntity.createNew('prop-2', {
propertyType: 'APARTMENT',
title: 'Căn hộ Thủ Đức',
description: 'Căn hộ 2PN',
address: makeAddress(),
location: makeGeoPoint(),
areaM2: 65,
usableAreaM2: null,
bedrooms: null,
bathrooms: null,
floors: null,
floor: 10,
totalFloors: 25,
direction: null,
yearBuilt: null,
legalStatus: null,
amenities: null,
nearbyPOIs: null,
metroDistanceM: null,
projectName: 'Vinhomes Grand Park',
});
expect(property.usableAreaM2).toBeNull();
expect(property.bedrooms).toBeNull();
expect(property.direction).toBeNull();
expect(property.floor).toBe(10);
expect(property.totalFloors).toBe(25);
expect(property.projectName).toBe('Vinhomes Grand Park');
});
});
describe('PropertyMediaEntity', () => {
it('creates media with all fields', () => {
const media = PropertyMediaEntity.createNew(
'media-1',
'prop-1',
'https://cdn.example.com/photo.jpg',
'image',
0,
'Mặt tiền nhà',
);
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).toBeNull();
});
it('creates media without caption', () => {
const media = PropertyMediaEntity.createNew(
'media-2',
'prop-1',
'https://cdn.example.com/video.mp4',
'video',
1,
);
expect(media.type).toBe('video');
expect(media.caption).toBeNull();
});
});
describe('Listings Value Objects', () => {
describe('Price', () => {
it('creates valid price', () => {
const result = Price.create(5_000_000_000n);
expect(result.isOk).toBe(true);
expect(result.unwrap().amountVND).toBe(5_000_000_000n);
});
it('rejects zero price', () => {
const result = Price.create(0n);
expect(result.isErr).toBe(true);
});
it('rejects negative price', () => {
const result = Price.create(-1_000_000n);
expect(result.isErr).toBe(true);
});
it('calculates price per m2', () => {
const price = Price.create(5_000_000_000n).unwrap();
const perM2 = price.calculatePerM2(100);
expect(perM2).toBe(50_000_000);
});
it('returns 0 for zero area', () => {
const price = Price.create(5_000_000_000n).unwrap();
expect(price.calculatePerM2(0)).toBe(0);
});
it('returns 0 for negative area', () => {
const price = Price.create(5_000_000_000n).unwrap();
expect(price.calculatePerM2(-10)).toBe(0);
});
});
describe('GeoPoint', () => {
it('creates valid geo point', () => {
const result = GeoPoint.create(10.7769, 106.7009);
expect(result.isOk).toBe(true);
const point = result.unwrap();
expect(point.latitude).toBe(10.7769);
expect(point.longitude).toBe(106.7009);
});
it('rejects invalid latitude above 90', () => {
const result = GeoPoint.create(91, 106);
expect(result.isErr).toBe(true);
});
it('rejects invalid latitude below -90', () => {
const result = GeoPoint.create(-91, 106);
expect(result.isErr).toBe(true);
});
it('rejects invalid longitude above 180', () => {
const result = GeoPoint.create(10, 181);
expect(result.isErr).toBe(true);
});
it('rejects invalid longitude below -180', () => {
const result = GeoPoint.create(10, -181);
expect(result.isErr).toBe(true);
});
it('generates WKT format', () => {
const point = GeoPoint.create(10.7769, 106.7009).unwrap();
expect(point.toWKT()).toBe('POINT(106.7009 10.7769)');
});
it('accepts boundary values', () => {
expect(GeoPoint.create(90, 180).isOk).toBe(true);
expect(GeoPoint.create(-90, -180).isOk).toBe(true);
});
});
describe('Address', () => {
it('creates valid address', () => {
const result = Address.create('123 Nguyễn Huệ', 'Bến Nghé', 'Quận 1', 'Hồ Chí Minh');
expect(result.isOk).toBe(true);
const addr = result.unwrap();
expect(addr.address).toBe('123 Nguyễn Huệ');
expect(addr.ward).toBe('Bến Nghé');
expect(addr.district).toBe('Quận 1');
expect(addr.city).toBe('Hồ Chí Minh');
});
it('generates full address string', () => {
const addr = Address.create('123 Nguyễn Huệ', 'Bến Nghé', 'Quận 1', 'Hồ Chí Minh').unwrap();
expect(addr.fullAddress).toBe('123 Nguyễn Huệ, Bến Nghé, Quận 1, Hồ Chí Minh');
});
it('trims whitespace', () => {
const addr = Address.create(' 123 Lê Lợi ', ' Bến Thành ', ' Quận 1 ', ' HCM ').unwrap();
expect(addr.address).toBe('123 Lê Lợi');
expect(addr.ward).toBe('Bến Thành');
});
it('rejects empty address', () => {
expect(Address.create('', 'Bến Nghé', 'Quận 1', 'HCM').isErr).toBe(true);
});
it('rejects empty ward', () => {
expect(Address.create('123', '', 'Quận 1', 'HCM').isErr).toBe(true);
});
it('rejects empty district', () => {
expect(Address.create('123', 'Bến Nghé', '', 'HCM').isErr).toBe(true);
});
it('rejects empty city', () => {
expect(Address.create('123', 'Bến Nghé', 'Quận 1', '').isErr).toBe(true);
});
it('rejects whitespace-only values', () => {
expect(Address.create(' ', 'Bến Nghé', 'Quận 1', 'HCM').isErr).toBe(true);
});
});
});

View File

@@ -1,17 +1,22 @@
import { McpModule as McpCoreModule, type McpRegistryService } from '@goodgo/mcp-servers';
import { Module, type OnModuleInit } from '@nestjs/common';
import { AuthModule } from '@modules/auth';
import { SearchModule } from '@modules/search';
import { type TypesenseClientService } from '@modules/search/infrastructure/services/typesense-client.service';
import { type LoggerService } from '@modules/shared/infrastructure/logger.service';
import { McpTransportController } from './presentation/mcp-transport.controller';
@Module({
imports: [
SearchModule,
AuthModule,
McpCoreModule.forRoot({
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
typesenseCollectionName: 'listings',
skipDefaultController: true,
}),
],
controllers: [McpTransportController],
})
export class McpIntegrationModule implements OnModuleInit {
constructor(

View File

@@ -0,0 +1,81 @@
import {
Controller,
Get,
Post,
Param,
Req,
Res,
HttpException,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { SSEServerTransport, type McpRegistryService } from '@goodgo/mcp-servers';
import type { Request, Response } from 'express';
import { JwtAuthGuard, CurrentUser, type JwtPayload } from '@modules/auth';
@Controller('mcp')
@UseGuards(JwtAuthGuard)
export class McpTransportController {
private readonly transports = new Map<string, SSEServerTransport>();
constructor(private readonly registry: McpRegistryService) {}
@Get('servers')
listServers(): { servers: string[] } {
return { servers: this.registry.getServerNames() };
}
@Get(':serverName/sse')
async handleSse(
@Param('serverName') serverName: string,
@CurrentUser() _user: JwtPayload,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const server = this.registry.getServer(serverName);
if (!server) {
throw new HttpException(
`MCP server "${serverName}" not found`,
HttpStatus.NOT_FOUND,
);
}
const transport = new SSEServerTransport(
`/mcp/${serverName}/messages`,
res,
);
this.transports.set(transport.sessionId, transport);
req.on('close', () => {
this.transports.delete(transport.sessionId);
});
await server.connect(transport);
}
@Post(':serverName/messages')
async handleMessage(
@Param('serverName') _serverName: string,
@CurrentUser() _user: JwtPayload,
@Req() req: Request,
@Res() res: Response,
): Promise<void> {
const sessionId = req.query['sessionId'] as string;
if (!sessionId) {
throw new HttpException(
'Missing sessionId query parameter',
HttpStatus.BAD_REQUEST,
);
}
const transport = this.transports.get(sessionId);
if (!transport) {
throw new HttpException(
'Session not found or expired',
HttpStatus.NOT_FOUND,
);
}
await transport.handlePostMessage(req, res);
}
}

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { NotificationSentEvent } from '../events/notification-sent.event';
import { NotificationChannel, ALL_CHANNELS } from '../value-objects/notification-channel.vo';
describe('Notifications Domain', () => {
describe('NotificationSentEvent', () => {
it('creates event with correct properties', () => {
const event = new NotificationSentEvent(
'notif-1',
'user-1',
NotificationChannel.EMAIL,
'listing_approved',
);
expect(event.eventName).toBe('notification.sent');
expect(event.aggregateId).toBe('notif-1');
expect(event.userId).toBe('user-1');
expect(event.channel).toBe('EMAIL');
expect(event.templateKey).toBe('listing_approved');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for PUSH channel', () => {
const event = new NotificationSentEvent(
'notif-2',
'user-2',
NotificationChannel.PUSH,
'payment_received',
);
expect(event.channel).toBe('PUSH');
});
it('creates event for SMS channel', () => {
const event = new NotificationSentEvent(
'notif-3',
'user-3',
NotificationChannel.SMS,
'kyc_approved',
);
expect(event.channel).toBe('SMS');
});
});
describe('NotificationChannel', () => {
it('defines all expected channels', () => {
expect(NotificationChannel.EMAIL).toBe('EMAIL');
expect(NotificationChannel.SMS).toBe('SMS');
expect(NotificationChannel.PUSH).toBe('PUSH');
expect(NotificationChannel.ZALO_OA).toBe('ZALO_OA');
});
it('ALL_CHANNELS contains all channel types', () => {
expect(ALL_CHANNELS).toHaveLength(4);
expect(ALL_CHANNELS).toContain('EMAIL');
expect(ALL_CHANNELS).toContain('SMS');
expect(ALL_CHANNELS).toContain('PUSH');
expect(ALL_CHANNELS).toContain('ZALO_OA');
});
});
});

View File

@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest';
import { PaymentCreatedEvent } from '../events/payment-created.event';
import { PaymentCompletedEvent } from '../events/payment-completed.event';
import { PaymentFailedEvent } from '../events/payment-failed.event';
describe('Payment Domain Events', () => {
describe('PaymentCreatedEvent', () => {
it('creates event with correct properties', () => {
const event = new PaymentCreatedEvent(
'payment-1',
'user-1',
'VNPAY',
'SUBSCRIPTION',
500_000n,
);
expect(event.eventName).toBe('payment.created');
expect(event.aggregateId).toBe('payment-1');
expect(event.userId).toBe('user-1');
expect(event.provider).toBe('VNPAY');
expect(event.type).toBe('SUBSCRIPTION');
expect(event.amountVND).toBe(500_000n);
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for LISTING_FEE type', () => {
const event = new PaymentCreatedEvent(
'payment-2',
'user-2',
'VNPAY',
'LISTING_FEE',
100_000n,
);
expect(event.type).toBe('LISTING_FEE');
});
});
describe('PaymentCompletedEvent', () => {
it('creates event with correct properties', () => {
const event = new PaymentCompletedEvent('payment-1', 'user-1', 'VNPAY', 500_000n);
expect(event.eventName).toBe('payment.completed');
expect(event.aggregateId).toBe('payment-1');
expect(event.userId).toBe('user-1');
expect(event.provider).toBe('VNPAY');
expect(event.amountVND).toBe(500_000n);
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('PaymentFailedEvent', () => {
it('creates event with correct properties', () => {
const event = new PaymentFailedEvent('payment-1', 'user-1', 'VNPAY');
expect(event.eventName).toBe('payment.failed');
expect(event.aggregateId).toBe('payment-1');
expect(event.userId).toBe('user-1');
expect(event.provider).toBe('VNPAY');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -0,0 +1,130 @@
import { describe, it, expect } from 'vitest';
import { ReviewEntity } from '../entities/review.entity';
import { ReviewCreatedEvent } from '../events/review-created.event';
import { ReviewDeletedEvent } from '../events/review-deleted.event';
import { Rating } from '../value-objects/rating.vo';
describe('Reviews Domain', () => {
describe('Rating', () => {
it('creates valid rating', () => {
const result = Rating.create(5);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(5);
});
it('creates rating with minimum value', () => {
const result = Rating.create(1);
expect(result.isOk).toBe(true);
expect(result.unwrap().value).toBe(1);
});
it('rejects rating below 1', () => {
const result = Rating.create(0);
expect(result.isErr).toBe(true);
});
it('rejects rating above 5', () => {
const result = Rating.create(6);
expect(result.isErr).toBe(true);
});
it('rejects non-integer rating', () => {
const result = Rating.create(3.5);
expect(result.isErr).toBe(true);
});
it('rejects negative rating', () => {
const result = Rating.create(-1);
expect(result.isErr).toBe(true);
});
});
describe('ReviewCreatedEvent', () => {
it('creates event with correct properties', () => {
const event = new ReviewCreatedEvent('review-1', 'user-1', 'agent', 'agent-1', 5);
expect(event.eventName).toBe('review.created');
expect(event.aggregateId).toBe('review-1');
expect(event.userId).toBe('user-1');
expect(event.targetType).toBe('agent');
expect(event.targetId).toBe('agent-1');
expect(event.rating).toBe(5);
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('ReviewDeletedEvent', () => {
it('creates event with correct properties', () => {
const event = new ReviewDeletedEvent('review-1', 'user-1', 'agent', 'agent-1');
expect(event.eventName).toBe('review.deleted');
expect(event.aggregateId).toBe('review-1');
expect(event.userId).toBe('user-1');
expect(event.targetType).toBe('agent');
expect(event.targetId).toBe('agent-1');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('ReviewEntity', () => {
it('creates new review with domain event', () => {
const rating = Rating.create(4).unwrap();
const review = ReviewEntity.createNew(
'review-1',
'user-1',
'agent',
'agent-1',
rating,
'Môi giới rất nhiệt tình',
);
expect(review.id).toBe('review-1');
expect(review.userId).toBe('user-1');
expect(review.targetType).toBe('agent');
expect(review.targetId).toBe('agent-1');
expect(review.rating.value).toBe(4);
expect(review.comment).toBe('Môi giới rất nhiệt tình');
const events = review.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(ReviewCreatedEvent);
});
it('creates review without comment', () => {
const rating = Rating.create(3).unwrap();
const review = ReviewEntity.createNew(
'review-2',
'user-2',
'property',
'prop-1',
rating,
null,
);
expect(review.comment).toBeNull();
});
it('emits ReviewDeletedEvent on markDeleted', () => {
const rating = Rating.create(5).unwrap();
const review = ReviewEntity.createNew(
'review-3',
'user-3',
'agent',
'agent-2',
rating,
null,
);
review.clearDomainEvents();
review.markDeleted();
const events = review.domainEvents;
expect(events).toHaveLength(1);
expect(events[0]).toBeInstanceOf(ReviewDeletedEvent);
const deleteEvent = events[0] as ReviewDeletedEvent;
expect(deleteEvent.userId).toBe('user-3');
expect(deleteEvent.targetType).toBe('agent');
expect(deleteEvent.targetId).toBe('agent-2');
});
});
});

View File

@@ -0,0 +1,122 @@
import { describe, it, expect } from 'vitest';
import { SearchFilter } from '../value-objects/search-filter.vo';
import { GeoFilter } from '../value-objects/geo-filter.vo';
describe('Search Domain', () => {
describe('SearchFilter', () => {
it('creates filter with all properties', () => {
const filter = SearchFilter.create({
query: 'nhà phố quận 1',
propertyType: 'TOWNHOUSE',
transactionType: 'SALE',
priceMin: 1_000_000_000,
priceMax: 5_000_000_000,
areaMin: 50,
areaMax: 200,
bedrooms: 3,
district: 'Quận 1',
city: 'Hồ Chí Minh',
sortBy: 'price_asc',
page: 2,
perPage: 10,
});
expect(filter.query).toBe('nhà phố quận 1');
expect(filter.propertyType).toBe('TOWNHOUSE');
expect(filter.transactionType).toBe('SALE');
expect(filter.priceMin).toBe(1_000_000_000);
expect(filter.priceMax).toBe(5_000_000_000);
expect(filter.areaMin).toBe(50);
expect(filter.areaMax).toBe(200);
expect(filter.bedrooms).toBe(3);
expect(filter.district).toBe('Quận 1');
expect(filter.city).toBe('Hồ Chí Minh');
expect(filter.sortBy).toBe('price_asc');
expect(filter.page).toBe(2);
expect(filter.perPage).toBe(10);
});
it('applies default sortBy as relevance', () => {
const filter = SearchFilter.create({});
expect(filter.sortBy).toBe('relevance');
});
it('applies default page as 1', () => {
const filter = SearchFilter.create({});
expect(filter.page).toBe(1);
});
it('applies default perPage as 20', () => {
const filter = SearchFilter.create({});
expect(filter.perPage).toBe(20);
});
it('caps perPage at 100', () => {
const filter = SearchFilter.create({ perPage: 500 });
expect(filter.perPage).toBe(100);
});
it('returns undefined for unset optional properties', () => {
const filter = SearchFilter.create({});
expect(filter.query).toBeUndefined();
expect(filter.propertyType).toBeUndefined();
expect(filter.transactionType).toBeUndefined();
expect(filter.priceMin).toBeUndefined();
expect(filter.priceMax).toBeUndefined();
expect(filter.areaMin).toBeUndefined();
expect(filter.areaMax).toBeUndefined();
expect(filter.bedrooms).toBeUndefined();
expect(filter.district).toBeUndefined();
expect(filter.city).toBeUndefined();
});
});
describe('GeoFilter', () => {
it('creates filter with all properties', () => {
const filter = GeoFilter.create({
lat: 10.7769,
lng: 106.7009,
radiusKm: 5,
propertyType: 'APARTMENT',
transactionType: 'RENT',
priceMin: 5_000_000,
priceMax: 20_000_000,
sortBy: 'distance',
page: 1,
perPage: 15,
});
expect(filter.lat).toBe(10.7769);
expect(filter.lng).toBe(106.7009);
expect(filter.radiusKm).toBe(5);
expect(filter.propertyType).toBe('APARTMENT');
expect(filter.transactionType).toBe('RENT');
expect(filter.priceMin).toBe(5_000_000);
expect(filter.priceMax).toBe(20_000_000);
expect(filter.sortBy).toBe('distance');
expect(filter.page).toBe(1);
expect(filter.perPage).toBe(15);
});
it('caps radiusKm at 100', () => {
const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 200 });
expect(filter.radiusKm).toBe(100);
});
it('applies default sortBy as distance', () => {
const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 });
expect(filter.sortBy).toBe('distance');
});
it('applies default page and perPage', () => {
const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5 });
expect(filter.page).toBe(1);
expect(filter.perPage).toBe(20);
});
it('caps perPage at 100', () => {
const filter = GeoFilter.create({ lat: 10.7, lng: 106.7, radiusKm: 5, perPage: 300 });
expect(filter.perPage).toBe(100);
});
});
});

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { SubscriptionCreatedEvent } from '../events/subscription-created.event';
import { SubscriptionUpgradedEvent } from '../events/subscription-upgraded.event';
import { SubscriptionCancelledEvent } from '../events/subscription-cancelled.event';
describe('Subscription Domain Events', () => {
describe('SubscriptionCreatedEvent', () => {
it('creates event with correct properties', () => {
const event = new SubscriptionCreatedEvent('sub-1', 'user-1', 'BASIC');
expect(event.eventName).toBe('subscription.created');
expect(event.aggregateId).toBe('sub-1');
expect(event.userId).toBe('user-1');
expect(event.planTier).toBe('BASIC');
expect(event.occurredAt).toBeInstanceOf(Date);
});
it('creates event for PREMIUM tier', () => {
const event = new SubscriptionCreatedEvent('sub-2', 'user-2', 'PREMIUM');
expect(event.planTier).toBe('PREMIUM');
});
});
describe('SubscriptionUpgradedEvent', () => {
it('creates event with correct properties', () => {
const event = new SubscriptionUpgradedEvent('sub-1', 'user-1', 'BASIC', 'PREMIUM');
expect(event.eventName).toBe('subscription.upgraded');
expect(event.aggregateId).toBe('sub-1');
expect(event.userId).toBe('user-1');
expect(event.fromTier).toBe('BASIC');
expect(event.toTier).toBe('PREMIUM');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
describe('SubscriptionCancelledEvent', () => {
it('creates event with correct properties', () => {
const event = new SubscriptionCancelledEvent('sub-1', 'user-1', 'PREMIUM');
expect(event.eventName).toBe('subscription.cancelled');
expect(event.aggregateId).toBe('sub-1');
expect(event.userId).toBe('user-1');
expect(event.planTier).toBe('PREMIUM');
expect(event.occurredAt).toBeInstanceOf(Date);
});
});
});

View File

@@ -3,6 +3,9 @@ export { createPropertySearchServer } from './property-search/property-search.se
export { createMarketAnalyticsServer } from './market-analytics/market-analytics.server';
export { createValuationServer } from './valuation/valuation.server';
// MCP SDK re-exports (for host apps that build custom controllers)
export { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
// NestJS integration
export { McpModule, type McpModuleOptions, MCP_MODULE_OPTIONS } from './nestjs/mcp.module';
export { McpRegistryService } from './nestjs/mcp-registry.service';

View File

@@ -6,6 +6,8 @@ import { MCP_MODULE_OPTIONS } from './mcp.constants';
export interface McpModuleOptions {
aiServiceBaseUrl: string;
typesenseCollectionName?: string;
/** When true, the built-in McpTransportController is NOT registered — useful when the host app provides its own authenticated controller. */
skipDefaultController?: boolean;
}
export { MCP_MODULE_OPTIONS };
@@ -20,7 +22,7 @@ export class McpModule {
return {
module: McpModule,
controllers: [McpTransportController],
controllers: options.skipDefaultController ? [] : [McpTransportController],
providers: [optionsProvider, McpRegistryService],
exports: [McpRegistryService],
global: false,