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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user