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:
111
apps/api/src/modules/admin/domain/__tests__/admin-events.spec.ts
Normal file
111
apps/api/src/modules/admin/domain/__tests__/admin-events.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user