diff --git a/.coderabbit.yaml b/.coderabbit.yaml index 70cfd06..7edab49 100644 --- a/.coderabbit.yaml +++ b/.coderabbit.yaml @@ -1,6 +1,7 @@ # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json language: 'en-US' early_access: true +tone_instructions: "Provide clear, objective, and detailed feedback with a professional and constructive tone, focusing on actionable suggestions for improvement." reviews: request_changes_workflow: true high_level_summary: true diff --git a/.eslintignore b/.eslintignore index a7ebf3f..2d84295 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1,6 +1,7 @@ node_modules/ dist/ lib/ +coverage/ docs/.docusaurus/ docs/build/ diff --git a/.gitignore b/.gitignore index 407fb72..45a2f1a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules .vscode lib -.env \ No newline at end of file +.env +coverage/ \ No newline at end of file diff --git a/.npmignore b/.npmignore index 5b05d69..4fb7648 100644 --- a/.npmignore +++ b/.npmignore @@ -3,6 +3,7 @@ node_modules src .gitignore .prettierrc +coverage/ *.md !README*.md diff --git a/.prettierignore b/.prettierignore index b079d09..2533e99 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,6 +1,7 @@ node_modules/ dist/ lib/ +coverage/ docs/.docusaurus/ docs/build/ diff --git a/jest.config.ts b/jest.config.ts index 6d6062b..21d4f24 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,4 +1,11 @@ +process.env.TZ = 'UTC'; + module.exports = { preset: 'ts-jest', testEnvironment: 'node', + coverageThreshold: { + global: { + lines: 80, + }, + }, }; diff --git a/package.json b/package.json index b7195bb..f5e393b 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build": "rimraf ./lib && tsc", "prepare": "npm run build", "test": "jest", + "test:cov": "jest --coverage", "release": "npm version patch && git push --follow-tags", "lint": "eslint .", "lint:fix": "eslint . --fix", diff --git a/src/utils/common.ts b/src/utils/common.ts index e0e7573..7035316 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,10 +1,12 @@ -import timezone from 'moment-timezone'; +import crypto, { BinaryLike } from 'node:crypto'; +import { tz, utc } from 'moment-timezone'; import { RESPONSE_MAP } from '../constants/response-map.constant'; import { HashAlgorithm, VnpLocale } from '../enums'; -import crypto, { BinaryLike } from 'crypto'; -export function getDateInGMT7(date: Date): Date { - return timezone(new Date()).tz('Asia/Ho_Chi_Minh').toDate(); +export function getDateInGMT7(date?: Date): Date { + return new Date( + tz(date ?? new Date(), 'Asia/Ho_Chi_Minh').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]'), + ); } /** @@ -40,7 +42,10 @@ export function dateFormat(date: Date, format = 'yyyyMMddHHmmss'): number { * @param dateNumber An vnpay date format number * @returns Date */ -export function parseDate(dateNumber: number | string): Date { +export function parseDate( + dateNumber: number | string, + tz: 'utc' | 'local' | 'gmt7' = 'local', +): Date { const dateString = dateNumber.toString(); const year = parseInt(dateString.slice(0, 4)); @@ -50,7 +55,19 @@ export function parseDate(dateNumber: number | string): Date { const minute = parseInt(dateString.slice(10, 12)); const second = parseInt(dateString.slice(12, 14)); - return new Date(year, month, day, hour, minute, second); + switch (tz) { + case 'utc': + return new Date( + utc([year, month, day, hour, minute, second], true).format( + 'YYYY-MM-DDTHH:mm:ss.SSS[Z]', + ), + ); + case 'gmt7': + return getDateInGMT7(new Date(year, month, day, hour, minute, second)); + case 'local': + default: + return new Date(year, month, day, hour, minute, second); + } } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index ecba500..e73c405 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,2 +1,3 @@ export * from './common'; export * from './logger'; +export * from './payment.util'; diff --git a/src/utils/logger.ts b/src/utils/logger.ts index 9a7c55c..9b7591b 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,4 +1,4 @@ -import * as fs from 'fs'; +import * as fs from 'node:fs'; /** * Truyền vào `loggerFn` để bỏ qua logger @@ -14,8 +14,10 @@ export function ignoreLogger(): void {} * @en Log data to console * @param data - Data to be logged */ -export function consoleLogger(data: unknown): void { - console.log(data); +export function consoleLogger(data: unknown, symbol: keyof Console = 'log'): void { + if (typeof console[symbol] === 'function') { + (console[symbol] as (...data: unknown[]) => void)(data); + } } /** diff --git a/src/utils/payment.util.ts b/src/utils/payment.util.ts new file mode 100644 index 0000000..281a9eb --- /dev/null +++ b/src/utils/payment.util.ts @@ -0,0 +1,53 @@ +import { PAYMENT_ENDPOINT, VNPAY_GATEWAY_SANDBOX_HOST } from '../constants'; +import { HashAlgorithm } from '../enums'; +import { BuildPaymentUrl, DefaultConfig, GlobalConfig } from '../types'; +import { hash, resolveUrlString } from './common'; + +export function buildPaymentUrlSearchParams(data: Record): URLSearchParams { + const searchParams = new URLSearchParams(); + Object.entries(data) + .sort(([key1], [key2]) => key1.toString().localeCompare(key2.toString())) + .forEach(([key, value]) => { + // Skip empty value + if (value === '' || value === undefined || value === null) { + return; + } + searchParams.set(key, value.toString()); + }); + return searchParams; +} + +export function createPaymentUrl( + config: Pick, + data: (BuildPaymentUrl & DefaultConfig) | Record, +): URL { + const redirectUrl = new URL( + resolveUrlString( + config.vnpayHost ?? VNPAY_GATEWAY_SANDBOX_HOST, + config.paymentEndpoint ?? PAYMENT_ENDPOINT, + ), + ); + buildPaymentUrlSearchParams(data).forEach((value, key) => { + redirectUrl.searchParams.set(key, value); + }); + return redirectUrl; +} + +export function calculateSecureHash( + secureSecret: string, + data: string, + hashAlgorithm: HashAlgorithm, + bufferEncode: BufferEncoding = 'utf-8', +): string { + return hash(secureSecret, Buffer.from(data, bufferEncode), hashAlgorithm); +} + +export function verifySecureHash( + secureSecret: string, + data: string, + hashAlgorithm: HashAlgorithm, + receivedHash: string, +): boolean { + const calculatedHash = calculateSecureHash(secureSecret, data, hashAlgorithm); + return calculatedHash === receivedHash; +} diff --git a/src/vnpay.ts b/src/vnpay.ts index 0f5b206..5a3f4fa 100644 --- a/src/vnpay.ts +++ b/src/vnpay.ts @@ -1,4 +1,3 @@ -import timezone from 'moment-timezone'; import { VNPAY_GATEWAY_SANDBOX_HOST, PAYMENT_ENDPOINT, @@ -14,6 +13,7 @@ import { import { HashAlgorithm, VnpCurrCode, VnpLocale, ProductCode } from './enums'; import { dateFormat, + getDateInGMT7, getResponseByStatusCode, hash, isValidVnpayDateFormat, @@ -51,6 +51,12 @@ import { Bank } from './types/bank.type'; import { DefaultConfig, GlobalConfig } from './types/common.type'; import { consoleLogger, ignoreLogger } from './utils'; import { LoggerOptions } from './types/logger.type'; +import { + buildPaymentUrlSearchParams, + calculateSecureHash, + createPaymentUrl, + verifySecureHash, +} from './utils/payment.util'; /** * Lớp hỗ trợ thanh toán qua VNPay @@ -184,39 +190,25 @@ export class VNPay { const dataToBuild = { ...this.defaultConfig, ...data, - }; - /** - * Multiply by 100 to follow VNPay standard, see docs for more detail - */ - dataToBuild.vnp_Amount = dataToBuild.vnp_Amount * 100; + /** + * Multiply by 100 to follow VNPay standard, see docs for more detail + */ + vnp_Amount: data.vnp_Amount * 100, + }; if (!isValidVnpayDateFormat(dataToBuild?.vnp_CreateDate ?? 0)) { - const timeGMT7 = timezone(new Date()).tz('Asia/Ho_Chi_Minh').format(); - dataToBuild.vnp_CreateDate = dateFormat(new Date(timeGMT7), 'yyyyMMddHHmmss'); + const timeGMT7 = getDateInGMT7(); + dataToBuild.vnp_CreateDate = dateFormat(timeGMT7, 'yyyyMMddHHmmss'); } - const redirectUrl = new URL( - resolveUrlString( - this.globalDefaultConfig.vnpayHost ?? VNPAY_GATEWAY_SANDBOX_HOST, - this.globalDefaultConfig.paymentEndpoint ?? PAYMENT_ENDPOINT, - ), - ); - Object.entries(dataToBuild) - .sort(([key1], [key2]) => key1.toString().localeCompare(key2.toString())) - .forEach(([key, value]) => { - // Skip empty value - if (!value || value === '' || value === undefined || value === null) { - return; - } - - redirectUrl.searchParams.append(key, value.toString()); - }); + const redirectUrl = createPaymentUrl(this.globalDefaultConfig, dataToBuild); - const signed = hash( + const signed = calculateSecureHash( this.globalDefaultConfig.secureSecret, - Buffer.from(redirectUrl.search.slice(1).toString(), this.BUFFER_ENCODE), + redirectUrl.search.slice(1).toString(), this.HASH_ALGORITHM, + this.BUFFER_ENCODE, ); redirectUrl.searchParams.append('vnp_SecureHash', signed); @@ -251,18 +243,26 @@ export class VNPay { query: ReturnQueryFromVNPay, options?: VerifyReturnUrlOptions, ): VerifyReturnUrl { - const { vnp_SecureHash, vnp_SecureHashType, ...cloneQuery } = query; + const { vnp_SecureHash = '', vnp_SecureHashType, ...cloneQuery } = query; if (typeof cloneQuery?.vnp_Amount !== 'number') { - const res = numberRegex.test(cloneQuery?.vnp_Amount ?? ''); - if (!res) { + const isValidAmount = numberRegex.test(cloneQuery?.vnp_Amount ?? ''); + if (!isValidAmount) { throw new Error('Invalid amount'); } cloneQuery.vnp_Amount = Number(cloneQuery.vnp_Amount); } + const searchParams = buildPaymentUrlSearchParams(cloneQuery); + const isVerified = verifySecureHash( + this.globalDefaultConfig.secureSecret, + searchParams.toString(), + this.HASH_ALGORITHM, + vnp_SecureHash, + ); + const outputResults = { - isVerified: true, + isVerified, isSuccess: cloneQuery.vnp_ResponseCode === '00', message: getResponseByStatusCode( cloneQuery.vnp_ResponseCode?.toString() ?? '', @@ -270,30 +270,12 @@ export class VNPay { ), }; - const searchParams = new URLSearchParams(); - Object.entries(cloneQuery) - .sort(([key1], [key2]) => key1.toString().localeCompare(key2.toString())) - .forEach(([key, value]) => { - // Skip empty value - if (value === '' || value === undefined || value === null) { - return; - } - - searchParams.append(key, value.toString()); - }); - - const signed = hash( - this.globalDefaultConfig.secureSecret, - Buffer.from(searchParams.toString(), this.BUFFER_ENCODE), - this.HASH_ALGORITHM, - ); - - if (vnp_SecureHash !== signed) { + if (!isVerified) { Object.assign(outputResults, { - isVerified: false, message: 'Wrong checksum', }); } + const result = { ...cloneQuery, ...outputResults, diff --git a/test/build-payment-url.test.ts b/test/build-payment-url.test.ts index 721e02d..68c314b 100644 --- a/test/build-payment-url.test.ts +++ b/test/build-payment-url.test.ts @@ -1,7 +1,7 @@ import { VNPay } from '../src/vnpay'; import { VnpLocale, ProductCode, VnpCurrCode } from '../src/enums'; import { BuildPaymentUrl } from '../src/types'; -import { consoleLogger, dateFormat, ignoreLogger } from '../src/utils'; +import { consoleLogger, dateFormat, getDateInGMT7, ignoreLogger } from '../src/utils'; describe('buildPaymentUrl', () => { let vnpay: VNPay; @@ -147,7 +147,7 @@ describe('buildPaymentUrl', () => { ...baseInput, }; delete input.vnp_CreateDate; - const currentTime = dateFormat(new Date()); + const currentTime = dateFormat(getDateInGMT7()); const result = vnpay.buildPaymentUrl(input); diff --git a/test/utils/common.test.ts b/test/utils/common.test.ts new file mode 100644 index 0000000..fdb9c3f --- /dev/null +++ b/test/utils/common.test.ts @@ -0,0 +1,128 @@ +import { utc } from 'moment-timezone'; +import { HashAlgorithm, VnpLocale } from '../../src/enums'; +import { + dateFormat, + generateRandomString, + getDateInGMT7, + getResponseByStatusCode, + hash, + isValidVnpayDateFormat, + parseDate, + resolveUrlString, +} from '../../src/utils/common'; + +describe('Common utils', () => { + let utcDate: Date; + beforeAll(() => { + utcDate = new Date(utc('2023-12-21T10:30:00Z').format('YYYY-MM-DDTHH:mm:ss.SSS[Z]')); + }); + + describe('getDateInGMT7', () => { + it('should return the correct date in GMT+7', () => { + const date = utcDate; + const gmt7Date = getDateInGMT7(date); + expect(gmt7Date.getHours()).toBe(17); + }); + }); + + describe('dateFormat', () => { + it('should format the date to the correct format', () => { + const date = utcDate; + const formattedDate = dateFormat(date); + expect(formattedDate).toBe(20231221103000); + }); + }); + + describe('parseDate', () => { + it('should parse the date number to the correct date', () => { + const dateNumber = 20231221103000; + const parsedDate = parseDate(dateNumber, 'utc'); + expect(parsedDate).toEqual(utcDate); + }); + + it('should parse the date string to the correct date', () => { + const dateString = '20231221103000'; + const parsedDate = parseDate(dateString, 'utc'); + expect(parsedDate).toEqual(utcDate); + }); + }); + + describe('isValidVnpayDateFormat', () => { + it('should return true for valid date', () => { + const date = 20231221103000; + expect(isValidVnpayDateFormat(date)).toBe(true); + }); + + it('should return false for invalid date', () => { + const date = 20231232103000; + expect(isValidVnpayDateFormat(date)).toBe(false); + }); + }); + + describe('generateRandomString', () => { + it('should generate a random string with the correct length', () => { + const length = 10; + const randomString = generateRandomString(length); + expect(randomString.length).toBe(length); + }); + + it('should generate a random string with only numbers', () => { + const length = 10; + const randomString = generateRandomString(length, { onlyNumber: true }); + expect(randomString).toMatch(/^\d+$/); + }); + }); + + describe('getResponseByStatusCode', () => { + it('should return the correct response message', () => { + const responseCode = '00'; + const locale = VnpLocale.VN; + const responseMessage = getResponseByStatusCode(responseCode, locale); + expect(responseMessage).toBe('Giao dịch thành công'); + }); + + it('should return the correct response message for default response code', () => { + const responseCode = '99'; + const locale = VnpLocale.VN; + const responseMessage = getResponseByStatusCode(responseCode, locale); + expect(responseMessage).toBe('Giao dịch thất bại'); + }); + + it('should return the default response message', () => { + const responseMessage = getResponseByStatusCode(); + expect(responseMessage).toBe('Giao dịch thất bại'); + }); + }); + + describe('resolveUrlString', () => { + it('should resolve url string correctly', () => { + expect(resolveUrlString('https://example.com/', '/path')).toBe( + 'https://example.com/path', + ); + expect(resolveUrlString('https://example.com', '/path')).toBe( + 'https://example.com/path', + ); + expect(resolveUrlString('https://example.com/', 'path')).toBe( + 'https://example.com/path', + ); + expect(resolveUrlString('https://example.com', 'path')).toBe( + 'https://example.com/path', + ); + expect(resolveUrlString('https://example.com///', '///path')).toBe( + 'https://example.com/path', + ); + }); + }); + + describe('hash', () => { + it('should hash data correctly', () => { + const secret = 'secret'; + const data = 'data'; + const algorithm = HashAlgorithm.SHA512; + const hashedData = hash(secret, data, algorithm); + expect(hashedData).toBe( + '6274071d33dec2728a2a1c903697fc1210b3252221c3d137e12d9f1ae5c8ed53e05e692b05a9eefff289667e2387c0fc0bd8a3d9bd7000782730c856a77a77d5', + ); + }); + }); +}); diff --git a/test/utils/logger.test.ts b/test/utils/logger.test.ts new file mode 100644 index 0000000..7c0a0ff --- /dev/null +++ b/test/utils/logger.test.ts @@ -0,0 +1,66 @@ +import * as fs from 'node:fs'; +import { consoleLogger, fileLogger, ignoreLogger } from '../../src/utils/logger'; + +jest.mock('node:fs'); + +describe('Logger', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should ignore logger', () => { + expect(ignoreLogger()).toBeUndefined(); + }); + + it('should log data to console', () => { + const logSpy = jest.spyOn(console, 'log'); + const data = 'test data'; + consoleLogger(data); + expect(logSpy).toHaveBeenCalledWith(data); + }); + + it('should log data to console with specific symbol', () => { + const errorSpy = jest.spyOn(console, 'error'); + const data = 'test error'; + consoleLogger(data, 'error'); + expect(errorSpy).toHaveBeenCalledWith(data); + }); + + it('should log data to file', () => { + const appendFileMock = jest + .spyOn(fs, 'appendFile') + .mockImplementation((_path, _data, callback) => callback(null)); + const data = { test: 'data' }; + const filePath = 'test.log'; + fileLogger(data, filePath); + expect(appendFileMock).toHaveBeenCalledWith( + filePath, + JSON.stringify(data) + '\n', + expect.any(Function), + ); + }); + + it('should handle file write error', () => { + const error = new Error('Write file error'); + const appendFileMock = jest + .spyOn(fs, 'appendFile') + .mockImplementation((_path, _data, callback) => callback(error)); + const errorCallback = jest.fn(); + const data = 'test data'; + const filePath = 'test.log'; + fileLogger(data, filePath, errorCallback); + expect(appendFileMock).toHaveBeenCalledWith(filePath, data + '\n', expect.any(Function)); + expect(errorCallback).toHaveBeenCalledWith(error); + }); + + it('should handle file write error without callback', () => { + const error = new Error('Write file error'); + const appendFileMock = jest + .spyOn(fs, 'appendFile') + .mockImplementation((_path, _data, callback) => callback(error)); + const data = 'test data'; + const filePath = 'test.log'; + expect(() => fileLogger(data, filePath)).toThrowError(error); + expect(appendFileMock).toHaveBeenCalledWith(filePath, data + '\n', expect.any(Function)); + }); +}); diff --git a/test/utils/payment-util.test.ts b/test/utils/payment-util.test.ts new file mode 100644 index 0000000..45da736 --- /dev/null +++ b/test/utils/payment-util.test.ts @@ -0,0 +1,88 @@ +import { + buildPaymentUrlSearchParams, + calculateSecureHash, + createPaymentUrl, + verifySecureHash, +} from '../../src/utils/payment.util'; +import { HashAlgorithm } from '../../src/enums'; +import { GlobalConfig } from '../../src/types'; + +describe('VNPay Payment Utility Functions', () => { + describe('buildPaymentUrlSearchParams', () => { + it('should build search params from data', () => { + const data = { + vnp_Amount: 10000, + vnp_TnxRef: '1234567890', + vnp_OrderInfo: 'Payment for order #1234567890', + }; + const searchParams = buildPaymentUrlSearchParams(data); + expect(searchParams.toString()).toBe( + 'vnp_Amount=10000&vnp_OrderInfo=Payment+for+order+%231234567890&vnp_TnxRef=1234567890', + ); + }); + it('should skip empty value', () => { + const data = { + vnp_Amount: 10000, + vnp_TnxRef: '1234567890', + vnp_OrderInfo: undefined, + }; + const searchParams = buildPaymentUrlSearchParams(data); + expect(searchParams.toString()).toBe('vnp_Amount=10000&vnp_TnxRef=1234567890'); + }); + }); + + describe('createPaymentUrl', () => { + it('should create payment url from config and data', () => { + const config = { + vnpayHost: 'https://sandbox.vnpayment.vn', + paymentEndpoint: '/paymentv2/vpcpay.html', + }; + const data = { + vnp_Amount: 10000, + vnp_TnxRef: '1234567890', + vnp_OrderInfo: 'Payment for order #1234567890', + }; + const paymentUrl = createPaymentUrl(config as GlobalConfig, data); + expect(paymentUrl.toString()).toBe( + 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?vnp_Amount=10000&vnp_OrderInfo=Payment+for+order+%231234567890&vnp_TnxRef=1234567890', + ); + }); + + it('should create a default payment url', () => { + const config = {}; + const data = { + vnp_Amount: 10000, + vnp_TnxRef: '1234567890', + vnp_OrderInfo: 'Payment for order #1234567890', + }; + const paymentUrl = createPaymentUrl(config as GlobalConfig, data); + expect(paymentUrl.toString()).toBe( + 'https://sandbox.vnpayment.vn/paymentv2/vpcpay.html?vnp_Amount=10000&vnp_OrderInfo=Payment+for+order+%231234567890&vnp_TnxRef=1234567890', + ); + }); + }); + describe('calculateSecureHash', () => { + it('should calculate secure hash from data', () => { + const secureSecret = 'YOUR_SECURE_SECRET'; + const data = + 'vnp_Amount=10000&vnp_OrderInfo=Payment+for+order+%231234567890&vnp_TnxRef=1234567890'; + const hashAlgorithm = HashAlgorithm.SHA512; + const secureHash = calculateSecureHash(secureSecret, data, hashAlgorithm); + expect(secureHash).toBe( + '7bb8160a5d2085a85b3267817a40ee4f770a335282f19714726e6fc6f28a64c75a1e7e9c1aa96f3cbda5ce44095088fb4cb2af66c596d2f655b0b53966312089', + ); + }); + }); + describe('verifySecureHash', () => { + it('should verify secure hash', () => { + const secureSecret = 'YOUR_SECURE_SECRET'; + const data = + 'vnp_Amount=10000&vnp_OrderInfo=Payment+for+order+%231234567890&vnp_TnxRef=1234567890'; + const hashAlgorithm = HashAlgorithm.SHA512; + const receivedHash = + '7bb8160a5d2085a85b3267817a40ee4f770a335282f19714726e6fc6f28a64c75a1e7e9c1aa96f3cbda5ce44095088fb4cb2af66c596d2f655b0b53966312089'; + const isVerified = verifySecureHash(secureSecret, data, hashAlgorithm, receivedHash); + expect(isVerified).toBe(true); + }); + }); +}); diff --git a/test/verify-return-url.test.ts b/test/verify-return-url.test.ts new file mode 100644 index 0000000..7bf1fb8 --- /dev/null +++ b/test/verify-return-url.test.ts @@ -0,0 +1,212 @@ +import { VNPay } from '../src/vnpay'; +import { ReturnQueryFromVNPay, VerifyReturnUrl } from '../src/types'; +import { consoleLogger, ignoreLogger } from '../src/utils'; + +describe('verifyReturnUrl', () => { + let vnpay: VNPay; + let validInput: ReturnQueryFromVNPay; + + beforeAll(() => { + vnpay = new VNPay({ + vnpayHost: 'http://sandbox.vnpayment.vn', + tmnCode: 'TEST_TMN_CODE', + secureSecret: 'test_secret', + testMode: true, + enableLog: true, + /** + * Ignore log global, since it's for test only + * If need test log feature, re-enable it in method scope + */ + loggerFn: ignoreLogger, + }); + validInput = { + vnp_Amount: 1000000, + vnp_BankCode: 'NCB', + vnp_BankTranNo: 'VNP14422574', + vnp_CardType: 'ATM', + vnp_OrderInfo: 'Thanh toan don hang 2024-05-21T02:17:31.249Z', + vnp_PayDate: '20240521091806', + vnp_ResponseCode: '00', + vnp_TmnCode: 'TEST_TMN_CODE', + vnp_TransactionNo: '14422574', + vnp_TransactionStatus: '00', + vnp_TxnRef: '1716257871703', + vnp_SecureHash: + '649982421beb422556a1374713c74e58e716ecbd05007c0c0e1f7effdfaf361dbf146889ca446139afe11b9954c4d5c486e99e3b86fcfab6957f29eb0be72895', + }; + }); + + it('should return a correct data with data input', () => { + // Arrange + const input: ReturnQueryFromVNPay = { ...validInput }; + const { + vnp_TxnRef, + vnp_OrderInfo, + vnp_BankCode, + vnp_BankTranNo, + vnp_CardType, + vnp_ResponseCode, + vnp_PayDate, + vnp_TmnCode, + vnp_TransactionNo, + vnp_TransactionStatus, + } = input; + const expectedOutput = { + vnp_TxnRef, + vnp_OrderInfo, + vnp_BankCode, + vnp_BankTranNo, + vnp_CardType, + vnp_ResponseCode, + vnp_PayDate, + vnp_TmnCode, + vnp_TransactionNo, + vnp_TransactionStatus, + }; + + // Act + const result = vnpay.verifyReturnUrl(input); + + // Assert + expect(result).toEqual(expect.objectContaining(expectedOutput)); + }); + + describe.each([ + [1000000, 10000], + [2000000, 20000], + [1650000, 16500], + [1234500, 12345], + [123450000, 1234500], + ])('should return a data with amount is divided by 100', (inputAmount, expectedAmount) => { + it(`should return ${expectedAmount} when input is ${inputAmount}`, () => { + // Arrange + const input: ReturnQueryFromVNPay = { + ...validInput, + vnp_Amount: inputAmount, + }; + + // Act + const result = vnpay.verifyReturnUrl(input); + + // Assert + expect(result).toEqual(expect.objectContaining({ vnp_Amount: expectedAmount })); + }); + }); + + it('should return a correct success result', () => { + // Act + const result = vnpay.verifyReturnUrl(validInput); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + isSuccess: true, + isVerified: true, + } as VerifyReturnUrl), + ); + }); + + it('should return a correct failed result', () => { + // Arrange + const input: ReturnQueryFromVNPay = { + ...validInput, + vnp_ResponseCode: '99', + vnp_SecureHash: + '503194f6d93b57b357b2d1d09742565e67e86d771352c5dca4fc8d39df3d392c6b3809add9405f97cfb9631f445e36c80656cfbf47eac67d97fa165865c13e1b', + }; + + // Act + const result = vnpay.verifyReturnUrl(input); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + isSuccess: false, + isVerified: true, + } as VerifyReturnUrl), + ); + }); + + it('should return a correct failed result with invalid secure hash', () => { + // Arrange + const input: ReturnQueryFromVNPay = { + ...validInput, + vnp_ResponseCode: '99', + }; + + // Act + const result = vnpay.verifyReturnUrl(input); + + // Assert + expect(result).toEqual( + expect.objectContaining({ + isSuccess: false, + isVerified: false, + } as VerifyReturnUrl), + ); + }); + + it('should throw error if amount is invalid', () => { + const query = { ...validInput, vnp_Amount: 'abc' }; + expect(() => vnpay.verifyReturnUrl(query)).toThrowError('Invalid amount'); + }); + + it('should convert vnp_Amount from string to number', () => { + // Arrange + const query = { ...validInput, vnp_Amount: '1000000' }; + + // Act + const result = vnpay.verifyReturnUrl(query); + + // Assert + expect(result).toEqual(expect.objectContaining({ vnp_Amount: 10000 })); + }); + + it('should log the object to the console', () => { + // Arrange + const input: ReturnQueryFromVNPay = { ...validInput }; + const consoleLogMock = jest.spyOn(console, 'log').mockImplementation(); + + // Act + vnpay.verifyReturnUrl(input, { + logger: { + loggerFn: consoleLogger, + }, + }); + + // Assert + expect(consoleLogMock).toHaveBeenCalledTimes(1); + expect(consoleLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + vnp_TxnRef: expect.any(String), + vnp_Amount: expect.any(Number), + }), + ); + consoleLogMock.mockRestore(); + }); + + it('should log the object to the console with secure hash', () => { + // Arrange + const input: ReturnQueryFromVNPay = { ...validInput }; + const consoleLogMock = jest.spyOn(console, 'log').mockImplementation(); + + // Act + vnpay.verifyReturnUrl(input, { + withHash: true, + logger: { + loggerFn: consoleLogger, + }, + }); + + // Assert + expect(consoleLogMock).toHaveBeenCalledTimes(1); + expect(consoleLogMock).toHaveBeenCalledWith( + expect.objectContaining({ + vnp_TxnRef: expect.any(String), + vnp_Amount: expect.any(Number), + vnp_SecureHash: expect.any(String), + }), + ); + consoleLogMock.mockRestore(); + }); +});