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 <noreply@paperclip.ing>
This commit is contained in:
@@ -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<typeof vi.fn> };
|
||||||
|
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; log: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof vi.fn> };
|
||||||
|
let mockMediaStorage: { [K in keyof IMediaStorageService]: ReturnType<typeof vi.fn> };
|
||||||
|
let mockCache: { invalidate: ReturnType<typeof vi.fn>; buildKey: ReturnType<typeof vi.fn> };
|
||||||
|
let mockLogger: { error: ReturnType<typeof vi.fn>; warn: ReturnType<typeof vi.fn>; log: ReturnType<typeof vi.fn> };
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,6 +11,7 @@ import { McpTransportController } from './presentation/mcp-transport.controller'
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
McpCoreModule.forRoot({
|
McpCoreModule.forRoot({
|
||||||
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
aiServiceBaseUrl: process.env['AI_SERVICE_URL'] || 'http://localhost:8000',
|
||||||
|
apiBaseUrl: process.env['API_BASE_URL'] || 'http://localhost:3001/api/v1',
|
||||||
typesenseCollectionName: 'listings',
|
typesenseCollectionName: 'listings',
|
||||||
skipDefaultController: true,
|
skipDefaultController: true,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
HttpStatus,
|
HttpStatus,
|
||||||
|
Inject,
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
@@ -12,7 +13,7 @@ import {
|
|||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { type CommandBus, type QueryBus } from '@nestjs/cqrs';
|
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 { JwtAuthGuard } from '@modules/auth';
|
||||||
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
import { RequireQuota, QuotaGuard } from '@modules/subscriptions';
|
||||||
import { DeleteReportCommand } from '../../application/commands/delete-report/delete-report.command';
|
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 { type ListReportsResult } from '../../application/queries/list-reports/list-reports.handler';
|
||||||
import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query';
|
import { ListReportsQuery } from '../../application/queries/list-reports/list-reports.query';
|
||||||
import { type ReportEntity } from '../../domain/entities/report.entity';
|
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 GenerateReportDto } from '../dto/generate-report.dto';
|
||||||
import { type ListReportsDto } from '../dto/list-reports.dto';
|
import { type ListReportsDto } from '../dto/list-reports.dto';
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ export class ReportsController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly commandBus: CommandBus,
|
private readonly commandBus: CommandBus,
|
||||||
private readonly queryBus: QueryBus,
|
private readonly queryBus: QueryBus,
|
||||||
|
@Inject(MACRO_DATA_SERVICE) private readonly macroDataService: IMacroDataService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('generate')
|
@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<string, MacroPoint[]>();
|
||||||
|
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')
|
@Get(':id')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import type { ReportsDeps } from '../shared/types';
|
|||||||
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
type ToolResult = { content: { type: string; text: string }[]; isError?: boolean };
|
||||||
|
|
||||||
function makeDeps(): ReportsDeps {
|
function makeDeps(): ReportsDeps {
|
||||||
return { aiServiceBaseUrl: 'http://localhost:8000' };
|
return { apiBaseUrl: 'http://localhost:3001/api/v1' };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolHandler(server: ReturnType<typeof createReportsServer>, name: string) {
|
function getToolHandler(server: ReturnType<typeof createReportsServer>, name: string) {
|
||||||
@@ -146,7 +146,7 @@ describe('ReportsServer', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledWith(
|
expect(fetchSpy).toHaveBeenCalledWith(
|
||||||
'http://localhost:8000/reports/generate',
|
'http://localhost:3001/api/v1/reports/generate',
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
@@ -229,7 +229,7 @@ describe('ReportsServer', () => {
|
|||||||
expect(data.highlights).toHaveLength(2);
|
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({
|
const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }),
|
json: async () => ({ province: 'Hồ Chí Minh', data: {}, highlights: [] }),
|
||||||
@@ -243,11 +243,17 @@ describe('ReportsServer', () => {
|
|||||||
toYear: 2025,
|
toYear: 2025,
|
||||||
});
|
});
|
||||||
|
|
||||||
const body = JSON.parse(fetchSpy.mock.calls[0][1]!.body as string) as Record<string, unknown>;
|
const calledUrl = fetchSpy.mock.calls[0][0] as string;
|
||||||
expect(body.province).toBe('Hồ Chí Minh');
|
expect(calledUrl).toContain('http://localhost:3001/api/v1/reports/macro-data?');
|
||||||
expect(body.categories).toEqual(['fdi', 'infrastructure']);
|
const url = new URL(calledUrl);
|
||||||
expect(body.from_year).toBe(2020);
|
expect(url.searchParams.get('province')).toBe('Hồ Chí Minh');
|
||||||
expect(body.to_year).toBe(2025);
|
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 () => {
|
it('returns error on service failure', async () => {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export class McpRegistryService implements OnModuleInit {
|
|||||||
this.servers.set(
|
this.servers.set(
|
||||||
'reports',
|
'reports',
|
||||||
createReportsServer({
|
createReportsServer({
|
||||||
aiServiceBaseUrl: this.options.aiServiceBaseUrl,
|
apiBaseUrl: this.options.apiBaseUrl ?? this.options.aiServiceBaseUrl,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { MCP_MODULE_OPTIONS } from './mcp.constants';
|
|||||||
|
|
||||||
export interface McpModuleOptions {
|
export interface McpModuleOptions {
|
||||||
aiServiceBaseUrl: string;
|
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;
|
typesenseCollectionName?: string;
|
||||||
/** When true, the built-in McpTransportController is NOT registered — useful when the host app provides its own authenticated controller. */
|
/** When true, the built-in McpTransportController is NOT registered — useful when the host app provides its own authenticated controller. */
|
||||||
skipDefaultController?: boolean;
|
skipDefaultController?: boolean;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ const GetMacroDataSchema = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function createReportsServer(deps: ReportsDeps): McpServer {
|
export function createReportsServer(deps: ReportsDeps): McpServer {
|
||||||
const baseUrl = deps.aiServiceBaseUrl.replace(/\/$/, '');
|
const baseUrl = deps.apiBaseUrl.replace(/\/$/, '');
|
||||||
|
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: 'goodgo-reports',
|
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.',
|
'Retrieve macro-economic data (GDP, population, FDI, infrastructure) for a Vietnamese province.',
|
||||||
GetMacroDataSchema,
|
GetMacroDataSchema,
|
||||||
async (params: z.infer<z.ZodObject<typeof GetMacroDataSchema>>) => {
|
async (params: z.infer<z.ZodObject<typeof GetMacroDataSchema>>) => {
|
||||||
const response = await fetch(`${baseUrl}/reports/macro-data`, {
|
const qs = new URLSearchParams();
|
||||||
method: 'POST',
|
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' },
|
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) {
|
if (!response.ok) {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface IndustrialParksDeps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ReportsDeps {
|
export interface ReportsDeps {
|
||||||
aiServiceBaseUrl: string;
|
apiBaseUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface McpServerConfig {
|
export interface McpServerConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user