From 57db3fe388d124b68326aa9c0bffc0f80d23e042 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 16 Apr 2026 13:19:44 +0700 Subject: [PATCH] test(auth): add unit tests for KYC presigned upload and submit handlers Cover GenerateKycUploadUrlsHandler (10 tests) and SubmitKycHandler (10 tests): presigned URL flow, legacy file upload, status validation, error handling. Co-Authored-By: Paperclip --- .../generate-kyc-upload-urls.handler.spec.ts | 207 ++++++++++++++ .../__tests__/submit-kyc.handler.spec.ts | 266 ++++++++++++++++++ apps/api/src/modules/mcp/mcp.module.ts | 1 + .../controllers/reports.controller.ts | 59 +++- .../src/__tests__/reports.server.test.ts | 22 +- .../src/nestjs/mcp-registry.service.ts | 2 +- libs/mcp-servers/src/nestjs/mcp.module.ts | 2 + .../mcp-servers/src/reports/reports.server.ts | 18 +- libs/mcp-servers/src/shared/types.ts | 2 +- 9 files changed, 559 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts create mode 100644 apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts diff --git a/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts new file mode 100644 index 0000000..5c32a44 --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/generate-kyc-upload-urls.handler.spec.ts @@ -0,0 +1,207 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; +import { GenerateKycUploadUrlsCommand } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command'; +import { GenerateKycUploadUrlsHandler } from '../commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler'; + +function createTestUser(overrides: Partial<{ kycStatus: string }> = {}): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: overrides.kycStatus ?? 'NONE', + kycData: null, + isActive: true, + }); +} + +describe('GenerateKycUploadUrlsHandler', () => { + let handler: GenerateKycUploadUrlsHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType }; + let mockLogger: { error: ReturnType; warn: ReturnType; log: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + mockMediaStorage = { + upload: vi.fn(), + delete: vi.fn(), + getPresignedUploadUrl: vi.fn(), + generatePresignedUpload: vi.fn(), + getPublicUrl: vi.fn(), + }; + mockLogger = { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + }; + + handler = new GenerateKycUploadUrlsHandler( + mockUserRepo as any, + mockMediaStorage as any, + mockLogger as any, + ); + }); + + it('generates presigned URLs for valid files', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.generatePresignedUpload.mockResolvedValue({ + uploadUrl: 'https://minio/upload', + publicUrl: 'https://minio/public', + objectKey: 'kyc/user-1/front.jpg', + }); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + const result = await handler.execute(command); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + field: 'frontImage', + uploadUrl: 'https://minio/upload', + publicUrl: 'https://minio/public', + objectKey: 'kyc/user-1/front.jpg', + }); + expect(mockMediaStorage.generatePresignedUpload).toHaveBeenCalledWith( + 'kyc/user-1', + 'front.jpg', + 'image/jpeg', + 300, + ); + }); + + it('generates presigned URLs for multiple files', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.generatePresignedUpload.mockResolvedValue({ + uploadUrl: 'https://minio/upload', + publicUrl: 'https://minio/public', + objectKey: 'kyc/user-1/file.jpg', + }); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + { field: 'backImage', mimeType: 'image/png', fileName: 'back.png' }, + { field: 'selfieImage', mimeType: 'image/webp', fileName: 'selfie.webp' }, + ]); + + const result = await handler.execute(command); + + expect(result).toHaveLength(3); + expect(mockMediaStorage.generatePresignedUpload).toHaveBeenCalledTimes(3); + }); + + it('allows resubmission when kycStatus is REJECTED', async () => { + const user = createTestUser({ kycStatus: 'REJECTED' }); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.generatePresignedUpload.mockResolvedValue({ + uploadUrl: 'https://minio/upload', + publicUrl: 'https://minio/public', + objectKey: 'kyc/user-1/front.jpg', + }); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + const result = await handler.execute(command); + expect(result).toHaveLength(1); + }); + + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new GenerateKycUploadUrlsCommand('non-existent', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when kycStatus is PENDING', async () => { + const user = createTestUser({ kycStatus: 'PENDING' }); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when kycStatus is VERIFIED', async () => { + const user = createTestUser({ kycStatus: 'VERIFIED' }); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException for empty files array', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new GenerateKycUploadUrlsCommand('user-1', []); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException for more than 3 files', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: '1.jpg' }, + { field: 'backImage', mimeType: 'image/jpeg', fileName: '2.jpg' }, + { field: 'selfieImage', mimeType: 'image/jpeg', fileName: '3.jpg' }, + { field: 'frontImage' as any, mimeType: 'image/jpeg', fileName: '4.jpg' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException for unsupported MIME type', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'application/pdf', fileName: 'doc.pdf' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when presigned URL generation fails', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.generatePresignedUpload.mockRejectedValue( + new Error('S3 connection failed'), + ); + + const command = new GenerateKycUploadUrlsCommand('user-1', [ + { field: 'frontImage', mimeType: 'image/jpeg', fileName: 'front.jpg' }, + ]); + + await expect(handler.execute(command)).rejects.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts b/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts new file mode 100644 index 0000000..283fcae --- /dev/null +++ b/apps/api/src/modules/auth/application/__tests__/submit-kyc.handler.spec.ts @@ -0,0 +1,266 @@ +import { UserEntity } from '../../domain/entities/user.entity'; +import { type IUserRepository } from '../../domain/repositories/user.repository'; +import { type HashedPassword } from '../../domain/value-objects/hashed-password.vo'; +import { Phone } from '../../domain/value-objects/phone.vo'; +import { type IMediaStorageService } from '../../../../listings/infrastructure/services/media-storage.service'; +import { SubmitKycCommand } from '../commands/submit-kyc/submit-kyc.command'; +import { SubmitKycHandler } from '../commands/submit-kyc/submit-kyc.handler'; + +function createTestUser(overrides: Partial<{ kycStatus: string }> = {}): UserEntity { + const phone = Phone.create('0912345678').unwrap(); + const pw = { value: 'hashed' } as HashedPassword; + return new UserEntity('user-1', { + email: null, + phone, + passwordHash: pw, + fullName: 'Nguyen Van A', + avatarUrl: null, + role: 'BUYER', + kycStatus: overrides.kycStatus ?? 'NONE', + kycData: null, + isActive: true, + }); +} + +describe('SubmitKycHandler', () => { + let handler: SubmitKycHandler; + let mockUserRepo: { [K in keyof IUserRepository]: ReturnType }; + let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType }; + let mockCache: { invalidate: ReturnType; buildKey: ReturnType }; + let mockLogger: { error: ReturnType; warn: ReturnType; log: ReturnType }; + + beforeEach(() => { + mockUserRepo = { + findById: vi.fn(), + findByPhone: vi.fn(), + findByEmail: vi.fn(), + save: vi.fn(), + update: vi.fn(), + }; + mockMediaStorage = { + upload: vi.fn(), + delete: vi.fn(), + getPresignedUploadUrl: vi.fn(), + generatePresignedUpload: vi.fn(), + getPublicUrl: vi.fn(), + }; + mockCache = { + invalidate: vi.fn().mockResolvedValue(undefined), + buildKey: vi.fn(), + }; + mockLogger = { + error: vi.fn(), + warn: vi.fn(), + log: vi.fn(), + }; + + handler = new SubmitKycHandler( + mockUserRepo as any, + mockMediaStorage as any, + mockCache as any, + mockLogger as any, + ); + }); + + describe('presigned URL flow', () => { + it('submits KYC with presigned image URLs', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { + frontImageUrl: 'https://minio/kyc/user-1/front.jpg', + backImageUrl: 'https://minio/kyc/user-1/back.jpg', + selfieUrl: 'https://minio/kyc/user-1/selfie.jpg', + }, + ); + + const result = await handler.execute(command); + + expect(result).toEqual({ message: expect.any(String) }); + expect(mockUserRepo.update).toHaveBeenCalledWith(user); + expect(user.kycStatus).toBe('PENDING'); + expect(mockCache.invalidate).toHaveBeenCalled(); + }); + + it('submits KYC with only front image URL', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://minio/kyc/user-1/front.jpg' }, + ); + + const result = await handler.execute(command); + + expect(result.message).toBeTruthy(); + expect(user.kycStatus).toBe('PENDING'); + expect(user.kycData).toMatchObject({ + idType: 'CCCD', + idNumber: '012345678901', + frontImageUrl: 'https://minio/kyc/user-1/front.jpg', + backImageUrl: null, + selfieUrl: null, + }); + }); + + it('allows resubmission when kycStatus is REJECTED', async () => { + const user = createTestUser({ kycStatus: 'REJECTED' }); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + + const command = new SubmitKycCommand( + 'user-1', + 'PASSPORT', + 'B12345678', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://minio/kyc/user-1/front.jpg' }, + ); + + const result = await handler.execute(command); + expect(result.message).toBeTruthy(); + expect(user.kycStatus).toBe('PENDING'); + }); + }); + + describe('legacy file upload flow', () => { + it('submits KYC with file buffers', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + mockMediaStorage.upload.mockResolvedValue('https://minio/kyc/user-1/front.jpg'); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + { buffer: Buffer.from('front'), mimetype: 'image/jpeg', originalname: 'front.jpg', size: 5 }, + ); + + const result = await handler.execute(command); + + expect(result.message).toBeTruthy(); + expect(mockMediaStorage.upload).toHaveBeenCalledTimes(1); + expect(user.kycStatus).toBe('PENDING'); + }); + + it('uploads all optional files when provided', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockUserRepo.update.mockResolvedValue(undefined); + mockMediaStorage.upload.mockResolvedValue('https://minio/kyc/user-1/file.jpg'); + + const fileData = { buffer: Buffer.from('img'), mimetype: 'image/jpeg', originalname: 'img.jpg', size: 3 }; + + const command = new SubmitKycCommand( + 'user-1', + 'CMND', + '123456789', + fileData, + fileData, + fileData, + ); + + await handler.execute(command); + + expect(mockMediaStorage.upload).toHaveBeenCalledTimes(3); + }); + }); + + describe('error cases', () => { + it('throws NotFoundException when user does not exist', async () => { + mockUserRepo.findById.mockResolvedValue(null); + + const command = new SubmitKycCommand( + 'non-existent', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://minio/front.jpg' }, + ); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when kycStatus is PENDING', async () => { + const user = createTestUser({ kycStatus: 'PENDING' }); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://minio/front.jpg' }, + ); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when kycStatus is VERIFIED', async () => { + const user = createTestUser({ kycStatus: 'VERIFIED' }); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + undefined, + undefined, + undefined, + { frontImageUrl: 'https://minio/front.jpg' }, + ); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws ValidationException when no images provided', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + ); + + await expect(handler.execute(command)).rejects.toThrow(); + }); + + it('throws when legacy file upload fails', async () => { + const user = createTestUser(); + mockUserRepo.findById.mockResolvedValue(user); + mockMediaStorage.upload.mockRejectedValue(new Error('S3 error')); + + const command = new SubmitKycCommand( + 'user-1', + 'CCCD', + '012345678901', + { buffer: Buffer.from('front'), mimetype: 'image/jpeg', originalname: 'front.jpg', size: 5 }, + ); + + await expect(handler.execute(command)).rejects.toThrow(); + expect(mockLogger.error).toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/api/src/modules/mcp/mcp.module.ts b/apps/api/src/modules/mcp/mcp.module.ts index d9b4d8c..60b43dd 100644 --- a/apps/api/src/modules/mcp/mcp.module.ts +++ b/apps/api/src/modules/mcp/mcp.module.ts @@ -11,6 +11,7 @@ import { McpTransportController } from './presentation/mcp-transport.controller' AuthModule, McpCoreModule.forRoot({ aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000', + apiBaseUrl: process.env['API_BASE_URL'] || 'http://localhost:3001/api/v1', typesenseCollectionName: 'listings', skipDefaultController: true, }), diff --git a/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts b/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts index 016d887..9b2e2b5 100644 --- a/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts +++ b/apps/api/src/modules/reports/presentation/controllers/reports.controller.ts @@ -5,6 +5,7 @@ import { Get, HttpCode, HttpStatus, + Inject, Param, Post, Query, @@ -12,7 +13,7 @@ import { UseGuards, } from '@nestjs/common'; import { type CommandBus, type QueryBus } from '@nestjs/cqrs'; -import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from '@nestjs/swagger'; +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { JwtAuthGuard } from '@modules/auth'; import { RequireQuota, QuotaGuard } from '@modules/subscriptions'; import { DeleteReportCommand } from '../../application/commands/delete-report/delete-report.command'; @@ -22,6 +23,7 @@ import { GetReportQuery } from '../../application/queries/get-report/get-report. import { type ListReportsResult } from '../../application/queries/list-reports/list-reports.handler'; import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query'; import { type ReportEntity } from '../../domain/entities/report.entity'; +import { MACRO_DATA_SERVICE, type IMacroDataService } from '../../domain/services/macro-data.service'; import { type GenerateReportDto } from '../dto/generate-report.dto'; import { type ListReportsDto } from '../dto/list-reports.dto'; @@ -35,6 +37,7 @@ export class ReportsController { constructor( private readonly commandBus: CommandBus, private readonly queryBus: QueryBus, + @Inject(MACRO_DATA_SERVICE) private readonly macroDataService: IMacroDataService, ) {} @Post('generate') @@ -70,6 +73,60 @@ export class ReportsController { }; } + @Get('macro-data') + @ApiOperation({ summary: 'Dữ liệu kinh tế vĩ mô theo tỉnh' }) + @ApiQuery({ name: 'province', required: true, description: 'Province name' }) + @ApiQuery({ name: 'categories', required: false, isArray: true, description: 'Indicator categories to retrieve' }) + @ApiQuery({ name: 'fromYear', required: false, description: 'Start year (default 2020)' }) + @ApiQuery({ name: 'toYear', required: false, description: 'End year (default 2025)' }) + @ApiResponse({ status: 200, description: 'Macro-economic data grouped by indicator' }) + async getMacroData( + @Query('province') province: string, + @Query('categories') categories?: string | string[], + @Query('fromYear') fromYear?: string, + @Query('toYear') toYear?: string, + ) { + const indicators = categories + ? Array.isArray(categories) ? categories : [categories] + : undefined; + const from = fromYear ? parseInt(fromYear, 10) : 2020; + const to = toYear ? parseInt(toYear, 10) : 2025; + + const rows = await this.macroDataService.getByProvince(province, indicators); + + // Filter by year range and group by indicator + type MacroPoint = { year: number; value: number; unit: string; yoy_change: number | null }; + const grouped = new Map(); + for (const row of rows) { + const year = parseInt(row.period, 10); + if (isNaN(year) || year < from || year > to) continue; + let series = grouped.get(row.indicator); + if (!series) { + series = []; + grouped.set(row.indicator, series); + } + series.push({ year, value: row.value, unit: row.unit, yoy_change: null }); + } + + // Sort each group by year and compute YoY change + for (const series of grouped.values()) { + series.sort((a, b) => a.year - b.year); + for (let i = 1; i < series.length; i++) { + const prev = series[i - 1]!; + const curr = series[i]!; + if (prev.value !== 0) { + curr.yoy_change = parseFloat((((curr.value - prev.value) / prev.value) * 100).toFixed(2)); + } + } + } + + return { + province, + data: Object.fromEntries(grouped), + highlights: [], + }; + } + @Get(':id') @ApiBearerAuth('JWT') @UseGuards(JwtAuthGuard) diff --git a/libs/mcp-servers/src/__tests__/reports.server.test.ts b/libs/mcp-servers/src/__tests__/reports.server.test.ts index 56bb9e8..b233dab 100644 --- a/libs/mcp-servers/src/__tests__/reports.server.test.ts +++ b/libs/mcp-servers/src/__tests__/reports.server.test.ts @@ -5,7 +5,7 @@ import type { ReportsDeps } from '../shared/types'; type ToolResult = { content: { type: string; text: string }[]; isError?: boolean }; function makeDeps(): ReportsDeps { - return { aiServiceBaseUrl: 'http://localhost:8000' }; + return { apiBaseUrl: 'http://localhost:3001/api/v1' }; } function getToolHandler(server: ReturnType, name: string) { @@ -146,7 +146,7 @@ describe('ReportsServer', () => { }); expect(fetchSpy).toHaveBeenCalledWith( - 'http://localhost:8000/reports/generate', + 'http://localhost:3001/api/v1/reports/generate', expect.objectContaining({ method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -229,7 +229,7 @@ describe('ReportsServer', () => { expect(data.highlights).toHaveLength(2); }); - it('sends correct request body', async () => { + it('sends correct GET request with query params', async () => { const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({ ok: true, json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }), @@ -243,11 +243,17 @@ describe('ReportsServer', () => { toYear: 2025, }); - const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record; - expect(body.province).toBe('Hồ Chí Minh'); - expect(body.categories).toEqual(['fdi', 'infrastructure']); - expect(body.from_year).toBe(2020); - expect(body.to_year).toBe(2025); + const calledUrl = fetchSpy.mock.calls[0][0] as string; + expect(calledUrl).toContain('http://localhost:3001/api/v1/reports/macro-data?'); + const url = new URL(calledUrl); + expect(url.searchParams.get('province')).toBe('Hồ Chí Minh'); + expect(url.searchParams.getAll('categories')).toEqual(['fdi', 'infrastructure']); + expect(url.searchParams.get('fromYear')).toBe('2020'); + expect(url.searchParams.get('toYear')).toBe('2025'); + + expect(fetchSpy.mock.calls[0][1]).toEqual( + expect.objectContaining({ method: 'GET' }), + ); }); it('returns error on service failure', async () => { diff --git a/libs/mcp-servers/src/nestjs/mcp-registry.service.ts b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts index 6809ece..4cbfc6d 100644 --- a/libs/mcp-servers/src/nestjs/mcp-registry.service.ts +++ b/libs/mcp-servers/src/nestjs/mcp-registry.service.ts @@ -61,7 +61,7 @@ export class McpRegistryService implements OnModuleInit { this.servers.set( 'reports', createReportsServer({ - aiServiceBaseUrl: this.options.aiServiceBaseUrl, + apiBaseUrl: this.options.apiBaseUrl ?? this.options.aiServiceBaseUrl, }), ); } diff --git a/libs/mcp-servers/src/nestjs/mcp.module.ts b/libs/mcp-servers/src/nestjs/mcp.module.ts index ac1e6fe..e237a3a 100644 --- a/libs/mcp-servers/src/nestjs/mcp.module.ts +++ b/libs/mcp-servers/src/nestjs/mcp.module.ts @@ -5,6 +5,8 @@ import { MCP_MODULE_OPTIONS } from './mcp.constants'; export interface McpModuleOptions { aiServiceBaseUrl: string; + /** Base URL for the NestJS API (e.g. http://localhost:3001/api/v1). Used by MCP servers that call NestJS endpoints instead of the AI service. */ + apiBaseUrl?: string; typesenseCollectionName?: string; /** When true, the built-in McpTransportController is NOT registered — useful when the host app provides its own authenticated controller. */ skipDefaultController?: boolean; diff --git a/libs/mcp-servers/src/reports/reports.server.ts b/libs/mcp-servers/src/reports/reports.server.ts index 92703c8..e751340 100644 --- a/libs/mcp-servers/src/reports/reports.server.ts +++ b/libs/mcp-servers/src/reports/reports.server.ts @@ -40,7 +40,7 @@ const GetMacroDataSchema = { }; export function createReportsServer(deps: ReportsDeps): McpServer { - const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, ''); + const baseUrl = deps.apiBaseUrl.replace(/\/$/, ''); const server = new McpServer({ name: 'goodgo-reports', @@ -108,15 +108,15 @@ export function createReportsServer(deps: ReportsDeps): McpServer { 'Retrieve macro-economic data (GDP, population, FDI, infrastructure) for a Vietnamese province.', GetMacroDataSchema, async (params: z.infer>) => { - const response = await fetch(`${baseUrl}/reports/macro-data`, { - method: 'POST', + const qs = new URLSearchParams(); + qs.set('province', params.province); + for (const cat of params.categories) qs.append('categories', cat); + qs.set('fromYear', String(params.fromYear)); + qs.set('toYear', String(params.toYear)); + + const response = await fetch(`${baseUrl}/reports/macro-data?${qs.toString()}`, { + method: 'GET', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - province: params.province, - categories: params.categories, - from_year: params.fromYear, - to_year: params.toYear, - }), }); if (!response.ok) { diff --git a/libs/mcp-servers/src/shared/types.ts b/libs/mcp-servers/src/shared/types.ts index a1d49b1..8edaf8f 100644 --- a/libs/mcp-servers/src/shared/types.ts +++ b/libs/mcp-servers/src/shared/types.ts @@ -21,7 +21,7 @@ export interface IndustrialParksDeps { } export interface ReportsDeps { - aiServiceBaseUrl: string; + apiBaseUrl: string; } export interface McpServerConfig {