feat(api): add Vietnam validators and migrate payment services to ConfigService
- Create custom class-validator decorators: IsVietnamPhone, IsVietnamDistrict, IsVND - Replace process.env/requireEnv() with NestJS ConfigService DI in VNPay, MoMo, ZaloPay services - Update all payment infrastructure tests with ConfigService mocks (42 tests passing) TEC-1569 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,35 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { type ConfigService } from '@nestjs/config';
|
||||||
import { MomoService } from '../services/momo.service';
|
import { MomoService } from '../services/momo.service';
|
||||||
|
|
||||||
describe('MomoService', () => {
|
describe('MomoService', () => {
|
||||||
let service: MomoService;
|
let service: MomoService;
|
||||||
const secretKey = 'test-momo-secret-key-32chars!!ab';
|
const secretKey = 'TESTSECRETKEY123456789012345678';
|
||||||
const partnerCode = 'MOMO_TEST';
|
const partnerCode = 'TESTPARTNER';
|
||||||
const accessKey = 'test-access-key';
|
const accessKey = 'TESTACCESSKEY';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv('MOMO_PARTNER_CODE', partnerCode);
|
const mockConfig = {
|
||||||
vi.stubEnv('MOMO_ACCESS_KEY', accessKey);
|
get: vi.fn((key: string, defaultValue?: string) => {
|
||||||
vi.stubEnv('MOMO_SECRET_KEY', secretKey);
|
const env: Record<string, string> = {
|
||||||
service = new MomoService();
|
'MOMO_PARTNER_CODE': 'TESTPARTNER',
|
||||||
|
'MOMO_ACCESS_KEY': 'TESTACCESSKEY',
|
||||||
|
'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678',
|
||||||
|
};
|
||||||
|
return env[key] ?? defaultValue;
|
||||||
|
}),
|
||||||
|
getOrThrow: vi.fn((key: string) => {
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
'MOMO_PARTNER_CODE': 'TESTPARTNER',
|
||||||
|
'MOMO_ACCESS_KEY': 'TESTACCESSKEY',
|
||||||
|
'MOMO_SECRET_KEY': 'TESTSECRETKEY123456789012345678',
|
||||||
|
};
|
||||||
|
if (!env[key]) throw new Error(`Missing ${key}`);
|
||||||
|
return env[key];
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
service = new MomoService(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildCallbackData(overrides: Record<string, string> = {}): Record<string, string> {
|
function buildCallbackData(overrides: Record<string, string> = {}): Record<string, string> {
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { type ConfigService } from '@nestjs/config';
|
||||||
import { MomoService } from '../services/momo.service';
|
import { MomoService } from '../services/momo.service';
|
||||||
import { PaymentGatewayFactory } from '../services/payment-gateway.factory';
|
import { PaymentGatewayFactory } from '../services/payment-gateway.factory';
|
||||||
import { VnpayService } from '../services/vnpay.service';
|
import { VnpayService } from '../services/vnpay.service';
|
||||||
import { ZalopayService } from '../services/zalopay.service';
|
import { ZalopayService } from '../services/zalopay.service';
|
||||||
|
|
||||||
describe('PaymentGatewayFactory', () => {
|
describe('PaymentGatewayFactory', () => {
|
||||||
const vnpay = new VnpayService();
|
const mockConfig = {
|
||||||
const momo = new MomoService();
|
get: vi.fn((key: string, defaultValue?: string) => defaultValue ?? 'test'),
|
||||||
const zalopay = new ZalopayService();
|
getOrThrow: vi.fn(() => 'test-value'),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
|
||||||
|
const vnpay = new VnpayService(mockConfig);
|
||||||
|
const momo = new MomoService(mockConfig);
|
||||||
|
const zalopay = new ZalopayService(mockConfig);
|
||||||
|
|
||||||
const factory = new PaymentGatewayFactory(vnpay, momo, zalopay);
|
const factory = new PaymentGatewayFactory(vnpay, momo, zalopay);
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,30 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { type ConfigService } from '@nestjs/config';
|
||||||
import { VnpayService } from '../services/vnpay.service';
|
import { VnpayService } from '../services/vnpay.service';
|
||||||
|
|
||||||
describe('VnpayService', () => {
|
describe('VnpayService', () => {
|
||||||
let service: VnpayService;
|
let service: VnpayService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv('VNPAY_TMN_CODE', 'TESTCODE');
|
const mockConfig = {
|
||||||
vi.stubEnv('VNPAY_HASH_SECRET', 'TESTSECRET123456TESTSECRET123456');
|
get: vi.fn((key: string, defaultValue?: string) => {
|
||||||
service = new VnpayService();
|
const env: Record<string, string> = {
|
||||||
|
VNPAY_TMN_CODE: 'TESTCODE',
|
||||||
|
VNPAY_HASH_SECRET: 'TESTSECRET123456TESTSECRET123456',
|
||||||
|
};
|
||||||
|
return env[key] ?? defaultValue;
|
||||||
|
}),
|
||||||
|
getOrThrow: vi.fn((key: string) => {
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
VNPAY_TMN_CODE: 'TESTCODE',
|
||||||
|
VNPAY_HASH_SECRET: 'TESTSECRET123456TESTSECRET123456',
|
||||||
|
};
|
||||||
|
if (!env[key]) throw new Error(`Missing ${key}`);
|
||||||
|
return env[key];
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
service = new VnpayService(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a payment URL', async () => {
|
it('should create a payment URL', async () => {
|
||||||
|
|||||||
@@ -1,16 +1,33 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
import { type ConfigService } from '@nestjs/config';
|
||||||
import { ZalopayService } from '../services/zalopay.service';
|
import { ZalopayService } from '../services/zalopay.service';
|
||||||
|
|
||||||
describe('ZalopayService', () => {
|
describe('ZalopayService', () => {
|
||||||
let service: ZalopayService;
|
let service: ZalopayService;
|
||||||
const key2 = 'test-zalopay-key2-for-callback!!';
|
const key2 = 'TESTKEY2ABCDEF1234567890ABCDEF12';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubEnv('ZALOPAY_APP_ID', '2553');
|
const mockConfig = {
|
||||||
vi.stubEnv('ZALOPAY_KEY1', 'test-zalopay-key1-for-signing!!a');
|
get: vi.fn((key: string, defaultValue?: string) => {
|
||||||
vi.stubEnv('ZALOPAY_KEY2', key2);
|
const env: Record<string, string> = {
|
||||||
service = new ZalopayService();
|
'ZALOPAY_APP_ID': '2553',
|
||||||
|
'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12',
|
||||||
|
'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12',
|
||||||
|
};
|
||||||
|
return env[key] ?? defaultValue;
|
||||||
|
}),
|
||||||
|
getOrThrow: vi.fn((key: string) => {
|
||||||
|
const env: Record<string, string> = {
|
||||||
|
'ZALOPAY_APP_ID': '2553',
|
||||||
|
'ZALOPAY_KEY1': 'TESTKEY1ABCDEF1234567890ABCDEF12',
|
||||||
|
'ZALOPAY_KEY2': 'TESTKEY2ABCDEF1234567890ABCDEF12',
|
||||||
|
};
|
||||||
|
if (!env[key]) throw new Error(`Missing ${key}`);
|
||||||
|
return env[key];
|
||||||
|
}),
|
||||||
|
} as unknown as ConfigService;
|
||||||
|
service = new ZalopayService(mockConfig);
|
||||||
});
|
});
|
||||||
|
|
||||||
function buildCallbackData(
|
function buildCallbackData(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
@@ -10,33 +11,21 @@ import {
|
|||||||
type RefundResult,
|
type RefundResult,
|
||||||
} from './payment-gateway.interface';
|
} from './payment-gateway.interface';
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MomoService implements IPaymentGateway {
|
export class MomoService implements IPaymentGateway {
|
||||||
private readonly logger = new Logger(MomoService.name);
|
private readonly logger = new Logger(MomoService.name);
|
||||||
readonly provider: PaymentProvider = 'MOMO';
|
readonly provider: PaymentProvider = 'MOMO';
|
||||||
|
|
||||||
private get partnerCode(): string {
|
private readonly partnerCode: string;
|
||||||
return requireEnv('MOMO_PARTNER_CODE');
|
private readonly accessKey: string;
|
||||||
}
|
private readonly secretKey: string;
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
private get accessKey(): string {
|
constructor(private readonly config: ConfigService) {
|
||||||
return requireEnv('MOMO_ACCESS_KEY');
|
this.partnerCode = this.config.getOrThrow<string>('MOMO_PARTNER_CODE');
|
||||||
}
|
this.accessKey = this.config.getOrThrow<string>('MOMO_ACCESS_KEY');
|
||||||
|
this.secretKey = this.config.getOrThrow<string>('MOMO_SECRET_KEY');
|
||||||
private get secretKey(): string {
|
this.endpoint = this.config.get<string>('MOMO_ENDPOINT', 'https://test-payment.momo.vn/v2/gateway/api');
|
||||||
return requireEnv('MOMO_SECRET_KEY');
|
|
||||||
}
|
|
||||||
|
|
||||||
private get endpoint(): string {
|
|
||||||
return process.env['MOMO_ENDPOINT'] ?? 'https://test-payment.momo.vn/v2/gateway/api';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
@@ -10,33 +11,21 @@ import {
|
|||||||
type RefundResult,
|
type RefundResult,
|
||||||
} from './payment-gateway.interface';
|
} from './payment-gateway.interface';
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class VnpayService implements IPaymentGateway {
|
export class VnpayService implements IPaymentGateway {
|
||||||
private readonly logger = new Logger(VnpayService.name);
|
private readonly logger = new Logger(VnpayService.name);
|
||||||
readonly provider: PaymentProvider = 'VNPAY';
|
readonly provider: PaymentProvider = 'VNPAY';
|
||||||
|
|
||||||
private get tmnCode(): string {
|
private readonly tmnCode: string;
|
||||||
return requireEnv('VNPAY_TMN_CODE');
|
private readonly hashSecret: string;
|
||||||
}
|
private readonly baseUrl: string;
|
||||||
|
private readonly apiUrl: string;
|
||||||
|
|
||||||
private get hashSecret(): string {
|
constructor(private readonly config: ConfigService) {
|
||||||
return requireEnv('VNPAY_HASH_SECRET');
|
this.tmnCode = this.config.getOrThrow<string>('VNPAY_TMN_CODE');
|
||||||
}
|
this.hashSecret = this.config.getOrThrow<string>('VNPAY_HASH_SECRET');
|
||||||
|
this.baseUrl = this.config.get<string>('VNPAY_BASE_URL', 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html');
|
||||||
private get baseUrl(): string {
|
this.apiUrl = this.config.get<string>('VNPAY_API_URL', 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction');
|
||||||
return process.env['VNPAY_BASE_URL'] ?? 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html';
|
|
||||||
}
|
|
||||||
|
|
||||||
private get apiUrl(): string {
|
|
||||||
return process.env['VNPAY_API_URL'] ?? 'https://sandbox.vnpayment.vn/merchant_webapi/api/transaction';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import * as crypto from 'crypto';
|
import * as crypto from 'crypto';
|
||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { type PaymentProvider } from '@prisma/client';
|
import { type PaymentProvider } from '@prisma/client';
|
||||||
import {
|
import {
|
||||||
type IPaymentGateway,
|
type IPaymentGateway,
|
||||||
@@ -10,33 +11,21 @@ import {
|
|||||||
type RefundResult,
|
type RefundResult,
|
||||||
} from './payment-gateway.interface';
|
} from './payment-gateway.interface';
|
||||||
|
|
||||||
function requireEnv(key: string): string {
|
|
||||||
const value = process.env[key];
|
|
||||||
if (!value) {
|
|
||||||
throw new Error(`Missing required environment variable: ${key}`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ZalopayService implements IPaymentGateway {
|
export class ZalopayService implements IPaymentGateway {
|
||||||
private readonly logger = new Logger(ZalopayService.name);
|
private readonly logger = new Logger(ZalopayService.name);
|
||||||
readonly provider: PaymentProvider = 'ZALOPAY';
|
readonly provider: PaymentProvider = 'ZALOPAY';
|
||||||
|
|
||||||
private get appId(): string {
|
private readonly appId: string;
|
||||||
return requireEnv('ZALOPAY_APP_ID');
|
private readonly key1: string;
|
||||||
}
|
private readonly key2: string;
|
||||||
|
private readonly endpoint: string;
|
||||||
|
|
||||||
private get key1(): string {
|
constructor(private readonly config: ConfigService) {
|
||||||
return requireEnv('ZALOPAY_KEY1');
|
this.appId = this.config.getOrThrow<string>('ZALOPAY_APP_ID');
|
||||||
}
|
this.key1 = this.config.getOrThrow<string>('ZALOPAY_KEY1');
|
||||||
|
this.key2 = this.config.getOrThrow<string>('ZALOPAY_KEY2');
|
||||||
private get key2(): string {
|
this.endpoint = this.config.get<string>('ZALOPAY_ENDPOINT', 'https://sb-openapi.zalopay.vn/v2');
|
||||||
return requireEnv('ZALOPAY_KEY2');
|
|
||||||
}
|
|
||||||
|
|
||||||
private get endpoint(): string {
|
|
||||||
return process.env['ZALOPAY_ENDPOINT'] ?? 'https://sb-openapi.zalopay.vn/v2';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
async createPaymentUrl(params: CreatePaymentUrlParams): Promise<CreatePaymentUrlResult> {
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
|
export { isValidVietnamPhone, normalizeVietnamPhone } from './vietnam-phone.validator';
|
||||||
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
|
export { formatVND, formatVNDCompact, parseVND } from './currency.formatter';
|
||||||
export { generateSlug } from './slug.generator';
|
export { generateSlug } from './slug.generator';
|
||||||
|
export { IsVietnamPhone, IsVietnamDistrict, IsVND, type IsVNDOptions } from './validators';
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
import { validate } from 'class-validator';
|
||||||
|
import { IsVietnamPhone } from '../is-vietnam-phone.validator';
|
||||||
|
import { IsVietnamDistrict } from '../is-vietnam-district.validator';
|
||||||
|
import { IsVND } from '../is-vnd.validator';
|
||||||
|
|
||||||
|
// Test DTOs
|
||||||
|
class PhoneDto {
|
||||||
|
@IsVietnamPhone()
|
||||||
|
phone!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class DistrictDto {
|
||||||
|
@IsVietnamDistrict()
|
||||||
|
district!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AmountDto {
|
||||||
|
@IsVND()
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
class CustomAmountDto {
|
||||||
|
@IsVND({ min: 10_000, max: 50_000_000 })
|
||||||
|
amount!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDto<T extends object>(Cls: new () => T, overrides: Partial<T>): T {
|
||||||
|
const dto = new Cls();
|
||||||
|
Object.assign(dto, overrides);
|
||||||
|
return dto;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IsVietnamPhone', () => {
|
||||||
|
it('accepts valid phone with 0 prefix', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '0912345678' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid phone with +84 prefix', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '+84912345678' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts valid phone with 84 prefix', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '84912345678' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts phone with spaces/dashes', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '091 234 5678' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects invalid phone number', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '12345' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isVietnamPhone');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string value', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: 12345 as unknown as string });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty string', async () => {
|
||||||
|
const dto = makeDto(PhoneDto, { phone: '' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IsVietnamDistrict', () => {
|
||||||
|
it('accepts "Quận 1"', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 'Quận 1' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts "Huyện Củ Chi"', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 'Huyện Củ Chi' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts "Bình Thạnh"', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 'Bình Thạnh' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts "Thành phố Thủ Đức"', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 'Thành phố Thủ Đức' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects single character', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 'Q' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isVietnamDistrict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects empty string', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: '' });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-string value', async () => {
|
||||||
|
const dto = makeDto(DistrictDto, { district: 123 as unknown as string });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('IsVND', () => {
|
||||||
|
it('accepts valid VND amount (number)', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 500_000 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts minimum amount (1)', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 1 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts maximum amount (100 tỷ)', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 100_000_000_000 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects zero', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 0 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
expect(errors[0].constraints).toHaveProperty('isVND');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects negative amount', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: -1000 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects amount exceeding max', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 100_000_000_001 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-integer', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 1000.5 });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects string value', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: '500000' as unknown as number });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('respects custom min/max options', async () => {
|
||||||
|
const dtoOk = makeDto(CustomAmountDto, { amount: 100_000 });
|
||||||
|
expect(await validate(dtoOk)).toHaveLength(0);
|
||||||
|
|
||||||
|
const dtoBelowMin = makeDto(CustomAmountDto, { amount: 5_000 });
|
||||||
|
expect(await validate(dtoBelowMin)).toHaveLength(1);
|
||||||
|
|
||||||
|
const dtoAboveMax = makeDto(CustomAmountDto, { amount: 60_000_000 });
|
||||||
|
expect(await validate(dtoAboveMax)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts bigint values', async () => {
|
||||||
|
const dto = makeDto(AmountDto, { amount: 500_000n as unknown as number });
|
||||||
|
const errors = await validate(dto);
|
||||||
|
expect(errors).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
3
apps/api/src/modules/shared/utils/validators/index.ts
Normal file
3
apps/api/src/modules/shared/utils/validators/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { IsVietnamPhone, IsVietnamPhoneConstraint } from './is-vietnam-phone.validator';
|
||||||
|
export { IsVietnamDistrict, IsVietnamDistrictConstraint } from './is-vietnam-district.validator';
|
||||||
|
export { IsVND, IsVNDConstraint, type IsVNDOptions } from './is-vnd.validator';
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
type ValidationArguments,
|
||||||
|
type ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vietnamese district name pattern — accepts:
|
||||||
|
* - "Quận 1", "Quận Bình Thạnh", "Huyện Củ Chi", "Thành phố Thủ Đức"
|
||||||
|
* - Plain names like "Bình Thạnh", "Củ Chi" (without prefix)
|
||||||
|
* Minimum 2 characters, maximum 100 characters, Vietnamese Unicode.
|
||||||
|
*/
|
||||||
|
const VN_DISTRICT_REGEX = /^[\p{L}\p{N}\s]{2,100}$/u;
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'isVietnamDistrict', async: false })
|
||||||
|
export class IsVietnamDistrictConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && VN_DISTRICT_REGEX.test(value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${args.property} phải là tên quận/huyện hợp lệ (VD: Quận 1, Huyện Củ Chi)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsVietnamDistrict(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsVietnamDistrictConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
type ValidationArguments,
|
||||||
|
type ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
import { isValidVietnamPhone } from '../vietnam-phone.validator';
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'isVietnamPhone', async: false })
|
||||||
|
export class IsVietnamPhoneConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown): boolean {
|
||||||
|
return typeof value === 'string' && isValidVietnamPhone(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
return `${args.property} phải là số điện thoại Việt Nam hợp lệ (VD: 0912345678, +84912345678)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsVietnamPhone(validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [],
|
||||||
|
validator: IsVietnamPhoneConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
registerDecorator,
|
||||||
|
type ValidationArguments,
|
||||||
|
type ValidationOptions,
|
||||||
|
ValidatorConstraint,
|
||||||
|
type ValidatorConstraintInterface,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
const VND_MIN = 1;
|
||||||
|
const VND_MAX = 100_000_000_000; // 100 tỷ VND
|
||||||
|
|
||||||
|
export interface IsVNDOptions {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ValidatorConstraint({ name: 'isVND', async: false })
|
||||||
|
export class IsVNDConstraint implements ValidatorConstraintInterface {
|
||||||
|
validate(value: unknown, args: ValidationArguments): boolean {
|
||||||
|
const opts = (args.constraints[0] as IsVNDOptions) ?? {};
|
||||||
|
const min = opts.min ?? VND_MIN;
|
||||||
|
const max = opts.max ?? VND_MAX;
|
||||||
|
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value >= BigInt(min) && value <= BigInt(max);
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) && value >= min && value <= max;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultMessage(args: ValidationArguments): string {
|
||||||
|
const opts = (args.constraints[0] as IsVNDOptions) ?? {};
|
||||||
|
const min = opts.min ?? VND_MIN;
|
||||||
|
const max = opts.max ?? VND_MAX;
|
||||||
|
return `${args.property} phải là số tiền VND hợp lệ (${min.toLocaleString('vi-VN')} - ${max.toLocaleString('vi-VN')} VND)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IsVND(options?: IsVNDOptions, validationOptions?: ValidationOptions) {
|
||||||
|
return function (object: object, propertyName: string) {
|
||||||
|
registerDecorator({
|
||||||
|
target: object.constructor,
|
||||||
|
propertyName,
|
||||||
|
options: validationOptions,
|
||||||
|
constraints: [options ?? {}],
|
||||||
|
validator: IsVNDConstraint,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user