diff --git a/apps/api/src/modules/admin/domain/__tests__/admin-events.spec.ts b/apps/api/src/modules/admin/domain/__tests__/admin-events.spec.ts new file mode 100644 index 0000000..b782c9d --- /dev/null +++ b/apps/api/src/modules/admin/domain/__tests__/admin-events.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/analytics/domain/__tests__/analytics-events.spec.ts b/apps/api/src/modules/analytics/domain/__tests__/analytics-events.spec.ts new file mode 100644 index 0000000..55b1e3f --- /dev/null +++ b/apps/api/src/modules/analytics/domain/__tests__/analytics-events.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts b/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts new file mode 100644 index 0000000..1cc90e7 --- /dev/null +++ b/apps/api/src/modules/auth/domain/__tests__/auth-events.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts b/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts new file mode 100644 index 0000000..36624b3 --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/listing-events.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts b/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts new file mode 100644 index 0000000..cf1bd7a --- /dev/null +++ b/apps/api/src/modules/listings/domain/__tests__/property.entity.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/mcp/mcp.module.ts b/apps/api/src/modules/mcp/mcp.module.ts index aba2738..db0b830 100644 --- a/apps/api/src/modules/mcp/mcp.module.ts +++ b/apps/api/src/modules/mcp/mcp.module.ts @@ -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( diff --git a/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts new file mode 100644 index 0000000..80c5746 --- /dev/null +++ b/apps/api/src/modules/mcp/presentation/mcp-transport.controller.ts @@ -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(); + + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/modules/notifications/domain/__tests__/notifications-domain.spec.ts b/apps/api/src/modules/notifications/domain/__tests__/notifications-domain.spec.ts new file mode 100644 index 0000000..9cc452c --- /dev/null +++ b/apps/api/src/modules/notifications/domain/__tests__/notifications-domain.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts b/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts new file mode 100644 index 0000000..0e1132d --- /dev/null +++ b/apps/api/src/modules/payments/domain/__tests__/payment-events.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/reviews/domain/__tests__/reviews-domain.spec.ts b/apps/api/src/modules/reviews/domain/__tests__/reviews-domain.spec.ts new file mode 100644 index 0000000..51000e3 --- /dev/null +++ b/apps/api/src/modules/reviews/domain/__tests__/reviews-domain.spec.ts @@ -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'); + }); + }); +}); diff --git a/apps/api/src/modules/search/domain/__tests__/search-domain.spec.ts b/apps/api/src/modules/search/domain/__tests__/search-domain.spec.ts new file mode 100644 index 0000000..164432f --- /dev/null +++ b/apps/api/src/modules/search/domain/__tests__/search-domain.spec.ts @@ -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); + }); + }); +}); diff --git a/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts new file mode 100644 index 0000000..9cfa1b9 --- /dev/null +++ b/apps/api/src/modules/subscriptions/domain/__tests__/subscription-events.spec.ts @@ -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); + }); + }); +}); diff --git a/libs/mcp-servers/src/index.ts b/libs/mcp-servers/src/index.ts index d14584f..f255b6d 100644 --- a/libs/mcp-servers/src/index.ts +++ b/libs/mcp-servers/src/index.ts @@ -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'; diff --git a/libs/mcp-servers/src/nestjs/mcp.module.ts b/libs/mcp-servers/src/nestjs/mcp.module.ts index 3a13825..ac1e6fe 100644 --- a/libs/mcp-servers/src/nestjs/mcp.module.ts +++ b/libs/mcp-servers/src/nestjs/mcp.module.ts @@ -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,