fix: harden alipay direct pay flow
This commit is contained in:
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
70
src/__tests__/app/api/order-status-route.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockVerifyAdminToken = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/admin-auth', () => ({
|
||||
verifyAdminToken: (...args: unknown[]) => mockVerifyAdminToken(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/api/orders/[id]/route';
|
||||
import { createOrderStatusAccessToken } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(orderId: string, accessToken?: string) {
|
||||
const url = new URL(`https://pay.example.com/api/orders/${orderId}`);
|
||||
if (accessToken) {
|
||||
url.searchParams.set('access_token', accessToken);
|
||||
}
|
||||
return new NextRequest(url);
|
||||
}
|
||||
|
||||
describe('GET /api/orders/[id]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockVerifyAdminToken.mockResolvedValue(false);
|
||||
mockFindUnique.mockResolvedValue({
|
||||
id: 'order-001',
|
||||
status: 'PENDING',
|
||||
expiresAt: new Date('2026-03-10T00:00:00.000Z'),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects requests without access token', async () => {
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
expect(response.status).toBe(401);
|
||||
});
|
||||
|
||||
it('returns order status with valid access token', async () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
const response = await GET(createRequest('order-001', token), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.id).toBe('order-001');
|
||||
expect(data.paymentSuccess).toBe(false);
|
||||
});
|
||||
|
||||
it('allows admin-authenticated access as fallback', async () => {
|
||||
mockVerifyAdminToken.mockResolvedValue(true);
|
||||
const response = await GET(createRequest('order-001'), { params: Promise.resolve({ id: 'order-001' }) });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
247
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
247
src/__tests__/app/pay/alipay-short-link-route.test.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
|
||||
const mockFindUnique = vi.fn();
|
||||
const mockBuildAlipayPaymentUrl = vi.fn();
|
||||
|
||||
vi.mock('@/lib/db', () => ({
|
||||
prisma: {
|
||||
order: {
|
||||
findUnique: (...args: unknown[]) => mockFindUnique(...args),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/alipay/provider', () => ({
|
||||
buildAlipayPaymentUrl: (...args: unknown[]) => mockBuildAlipayPaymentUrl(...args),
|
||||
}));
|
||||
|
||||
import { GET } from '@/app/pay/[orderId]/route';
|
||||
import { buildOrderResultUrl } from '@/lib/order/status-access';
|
||||
|
||||
function createRequest(userAgent = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)') {
|
||||
return new NextRequest('https://pay.example.com/pay/order-001', {
|
||||
headers: { 'user-agent': userAgent },
|
||||
});
|
||||
}
|
||||
|
||||
function createPendingOrder(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'order-001',
|
||||
amount: 88,
|
||||
payAmount: 100.5,
|
||||
paymentType: 'alipay_direct',
|
||||
status: ORDER_STATUS.PENDING,
|
||||
expiresAt: new Date(Date.now() + 5 * 60 * 1000),
|
||||
paidAt: null,
|
||||
completedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('GET /pay/[orderId]', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mock=1');
|
||||
});
|
||||
|
||||
it('returns 404 error page when order does not exist', async () => {
|
||||
mockFindUnique.mockResolvedValue(null);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'missing-order' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(html).toContain('订单不存在');
|
||||
expect(html).toContain('missing-order');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-alipay orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
paymentType: 'wxpay_direct',
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(html).toContain('支付方式不匹配');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns success status page for completed orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: new Date('2026-03-09T10:00:03Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('充值成功');
|
||||
expect(html).toContain('余额已到账');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns paid-but-recharge-failed status page for failed paid orders', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('支付成功');
|
||||
expect(html).toContain('余额充值暂未完成');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns expired status page when order is timed out', async () => {
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
expiresAt: new Date(Date.now() - 1000),
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('订单超时');
|
||||
expect(html).toContain('订单已超时');
|
||||
expect(mockBuildAlipayPaymentUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('builds desktop redirect page with service-generated alipay url and no manual pay button', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder());
|
||||
|
||||
const response = await GET(createRequest(), {
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
});
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?desktop=1');
|
||||
expect(html).toContain('http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(html).toContain('查看订单结果');
|
||||
expect(html).toContain('order_id=order-001');
|
||||
expect(html).toContain('access_token=');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 100.5,
|
||||
subject: 'Sub2API Balance Recharge 100.50 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds mobile redirect page with wap alipay url', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
mockFindUnique.mockResolvedValue(
|
||||
createPendingOrder({
|
||||
payAmount: null,
|
||||
amount: 88,
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148',
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
const expectedReturnUrl = buildOrderResultUrl('https://pay.example.com', 'order-001');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('正在拉起支付宝');
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?mobile=1');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 88,
|
||||
subject: 'Sub2API Balance Recharge 88.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: expectedReturnUrl,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('omits returnUrl for Alipay app requests to avoid extra close step', async () => {
|
||||
mockBuildAlipayPaymentUrl.mockReturnValue('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
mockFindUnique.mockResolvedValue(createPendingOrder({ payAmount: 66 }));
|
||||
|
||||
const response = await GET(
|
||||
createRequest(
|
||||
'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 Mobile/15E148 AlipayClient/10.5.90',
|
||||
),
|
||||
{
|
||||
params: Promise.resolve({ orderId: 'order-001' }),
|
||||
},
|
||||
);
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(html).toContain('https://openapi.alipay.com/gateway.do?alipayapp=1');
|
||||
expect(html).toContain('window.location.replace(payUrl)');
|
||||
expect(html).toContain('<noscript><meta http-equiv="refresh"');
|
||||
expect(html).not.toContain('立即前往支付宝');
|
||||
expect(mockBuildAlipayPaymentUrl).toHaveBeenCalledWith({
|
||||
orderId: 'order-001',
|
||||
amount: 66,
|
||||
subject: 'Sub2API Balance Recharge 66.00 CNY',
|
||||
notifyUrl: 'https://pay.example.com/api/alipay/notify',
|
||||
returnUrl: null,
|
||||
isMobile: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
98
src/__tests__/lib/alipay/client.test.ts
Normal file
98
src/__tests__/lib/alipay/client.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ALIPAY_APP_ID: '2021000000000000',
|
||||
ALIPAY_PRIVATE_KEY: 'test-private-key',
|
||||
ALIPAY_PUBLIC_KEY: 'test-public-key',
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
}),
|
||||
}));
|
||||
|
||||
const { mockGenerateSign } = vi.hoisted(() => ({
|
||||
mockGenerateSign: vi.fn(() => 'signed-value'),
|
||||
}));
|
||||
vi.mock('@/lib/alipay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
}));
|
||||
|
||||
import { execute, pageExecute } from '@/lib/alipay/client';
|
||||
|
||||
describe('alipay client helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('pageExecute includes notify_url and return_url by default', () => {
|
||||
const url = new URL(
|
||||
pageExecute({ out_trade_no: 'order-001', product_code: 'FAST_INSTANT_TRADE_PAY', total_amount: '10.00' }),
|
||||
);
|
||||
|
||||
expect(url.origin + url.pathname).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
expect(url.searchParams.get('return_url')).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.page.pay');
|
||||
expect(url.searchParams.get('sign')).toBe('signed-value');
|
||||
});
|
||||
|
||||
it('pageExecute omits return_url when explicitly disabled', () => {
|
||||
const url = new URL(
|
||||
pageExecute(
|
||||
{ out_trade_no: 'order-002', product_code: 'QUICK_WAP_WAY', total_amount: '20.00' },
|
||||
{ returnUrl: null, method: 'alipay.trade.wap.pay' },
|
||||
),
|
||||
);
|
||||
|
||||
expect(url.searchParams.get('method')).toBe('alipay.trade.wap.pay');
|
||||
expect(url.searchParams.get('return_url')).toBeNull();
|
||||
expect(url.searchParams.get('notify_url')).toBe('https://pay.example.com/api/alipay/notify');
|
||||
});
|
||||
|
||||
it('execute posts form data and returns the named response payload', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
},
|
||||
sign: 'server-sign',
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await execute('alipay.trade.query', { out_trade_no: 'order-003' });
|
||||
|
||||
expect(result).toEqual({ code: '10000', msg: 'Success', trade_status: 'TRADE_SUCCESS' });
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://openapi.alipay.com/gateway.do');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
expect(String(init.body)).toContain('method=alipay.trade.query');
|
||||
});
|
||||
|
||||
it('execute throws when alipay response code is not successful', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
alipay_trade_query_response: {
|
||||
code: '40004',
|
||||
msg: 'Business Failed',
|
||||
sub_code: 'ACQ.TRADE_NOT_EXIST',
|
||||
sub_msg: 'trade not exist',
|
||||
},
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json; charset=utf-8' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(execute('alipay.trade.query', { out_trade_no: 'order-004' })).rejects.toThrow(
|
||||
'[ACQ.TRADE_NOT_EXIST] trade not exist',
|
||||
);
|
||||
});
|
||||
});
|
||||
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
31
src/__tests__/lib/alipay/codec.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { decodeAlipayPayload, parseAlipayNotificationParams } from '@/lib/alipay/codec';
|
||||
|
||||
describe('Alipay codec', () => {
|
||||
it('should normalize plus signs in notify sign parameter', () => {
|
||||
const params = parseAlipayNotificationParams(Buffer.from('sign=abc+def&trade_no=1'), {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(params.sign).toBe('abc+def');
|
||||
expect(params.trade_no).toBe('1');
|
||||
});
|
||||
|
||||
it('should decode payload charset from content-type header', () => {
|
||||
const body = Buffer.from('charset=utf-8&trade_status=TRADE_SUCCESS', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body, {
|
||||
'content-type': 'application/x-www-form-urlencoded; charset=utf-8',
|
||||
});
|
||||
|
||||
expect(decoded).toContain('trade_status=TRADE_SUCCESS');
|
||||
});
|
||||
|
||||
it('should fallback to body charset hint when header is missing', () => {
|
||||
const body = Buffer.from('charset=gbk&trade_no=202603090001', 'utf-8');
|
||||
|
||||
const decoded = decodeAlipayPayload(body);
|
||||
|
||||
expect(decoded).toContain('trade_no=202603090001');
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ vi.mock('@/lib/config', () => ({
|
||||
ALIPAY_NOTIFY_URL: 'https://pay.example.com/api/alipay/notify',
|
||||
ALIPAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
NEXT_PUBLIC_APP_URL: 'https://pay.example.com',
|
||||
PRODUCT_NAME: 'Sub2API Balance Recharge',
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -25,7 +26,7 @@ vi.mock('@/lib/alipay/sign', () => ({
|
||||
verifySign: (...args: unknown[]) => mockVerifySign(...args),
|
||||
}));
|
||||
|
||||
import { AlipayProvider } from '@/lib/alipay/provider';
|
||||
import { AlipayProvider, buildAlipayEntryUrl } from '@/lib/alipay/provider';
|
||||
import type { CreatePaymentRequest, RefundRequest } from '@/lib/payment/types';
|
||||
|
||||
describe('AlipayProvider', () => {
|
||||
@@ -57,13 +58,11 @@ describe('AlipayProvider', () => {
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should call pageExecute and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
it('should return service short link as desktop qrCode', async () => {
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
paymentType: 'alipay',
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
};
|
||||
@@ -71,16 +70,42 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(result.payUrl).toBe('https://pay.example.com/pay/order-001');
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
expect(mockPageExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should build short link from app url', () => {
|
||||
expect(buildAlipayEntryUrl('order-short-link')).toBe('https://pay.example.com/pay/order-short-link');
|
||||
});
|
||||
|
||||
it('should call pageExecute for mobile and return payUrl', async () => {
|
||||
mockPageExecute.mockReturnValue('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
paymentType: 'alipay_direct',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
clientIp: '127.0.0.1',
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-002');
|
||||
expect(result.payUrl).toBe('https://openapi.alipay.com/gateway.do?app_id=xxx&sign=yyy');
|
||||
expect(mockPageExecute).toHaveBeenCalledWith(
|
||||
{
|
||||
out_trade_no: 'order-001',
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
total_amount: '100.00',
|
||||
subject: 'Sub2API Balance Recharge 100.00 CNY',
|
||||
out_trade_no: 'order-002',
|
||||
product_code: 'QUICK_WAP_WAY',
|
||||
total_amount: '50.00',
|
||||
subject: 'Sub2API Balance Recharge 50.00 CNY',
|
||||
},
|
||||
expect.objectContaining({}),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||
);
|
||||
expect(mockExecute).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -140,6 +165,15 @@ describe('AlipayProvider', () => {
|
||||
const result = await provider.queryOrder('order-004');
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should treat ACQ.TRADE_NOT_EXIST as pending', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
const result = await provider.queryOrder('order-005');
|
||||
expect(result.tradeNo).toBe('order-005');
|
||||
expect(result.status).toBe('pending');
|
||||
expect(result.amount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifyNotification', () => {
|
||||
@@ -188,7 +222,7 @@ describe('AlipayProvider', () => {
|
||||
trade_no: '2026030500003',
|
||||
out_trade_no: 'order-003',
|
||||
trade_status: 'TRADE_CLOSED',
|
||||
total_amount: '30.00',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
@@ -198,80 +232,98 @@ describe('AlipayProvider', () => {
|
||||
expect(result.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('should throw on invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
it('should reject unsupported sign_type', async () => {
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500004',
|
||||
out_trade_no: 'order-004',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Unsupported sign_type');
|
||||
});
|
||||
|
||||
it('should reject invalid signature', async () => {
|
||||
mockVerifySign.mockReturnValue(false);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500005',
|
||||
out_trade_no: 'order-005',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'bad_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000000000',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow(
|
||||
'Alipay notification signature verification failed',
|
||||
);
|
||||
});
|
||||
|
||||
it('should reject app_id mismatch', async () => {
|
||||
mockVerifySign.mockReturnValue(true);
|
||||
|
||||
const body = new URLSearchParams({
|
||||
trade_no: '2026030500006',
|
||||
out_trade_no: 'order-006',
|
||||
trade_status: 'TRADE_SUCCESS',
|
||||
total_amount: '20.00',
|
||||
sign: 'test_sign',
|
||||
sign_type: 'RSA2',
|
||||
app_id: '2021000000009999',
|
||||
}).toString();
|
||||
|
||||
await expect(provider.verifyNotification(body, {})).rejects.toThrow('Alipay notification app_id mismatch');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('should call alipay.trade.refund and return success', async () => {
|
||||
it('should request refund and map success status', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
trade_no: 'refund-trade-no',
|
||||
fund_change: 'Y',
|
||||
});
|
||||
|
||||
const request: RefundRequest = {
|
||||
tradeNo: '2026030500001',
|
||||
orderId: 'order-001',
|
||||
amount: 100,
|
||||
reason: 'customer request',
|
||||
tradeNo: 'trade-no',
|
||||
orderId: 'order-refund',
|
||||
amount: 12.34,
|
||||
reason: 'test refund',
|
||||
};
|
||||
|
||||
const result = await provider.refund(request);
|
||||
expect(result.refundId).toBe('2026030500001');
|
||||
expect(result.status).toBe('success');
|
||||
|
||||
expect(result).toEqual({ refundId: 'refund-trade-no', status: 'success' });
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.refund', {
|
||||
out_trade_no: 'order-001',
|
||||
refund_amount: '100.00',
|
||||
refund_reason: 'customer request',
|
||||
out_request_no: 'order-001-refund',
|
||||
out_trade_no: 'order-refund',
|
||||
refund_amount: '12.34',
|
||||
refund_reason: 'test refund',
|
||||
out_request_no: 'order-refund-refund',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return pending when fund_change is N', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500002',
|
||||
fund_change: 'N',
|
||||
});
|
||||
|
||||
const result = await provider.refund({
|
||||
tradeNo: '2026030500002',
|
||||
orderId: 'order-002',
|
||||
amount: 50,
|
||||
});
|
||||
expect(result.status).toBe('pending');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelPayment', () => {
|
||||
it('should call alipay.trade.close', async () => {
|
||||
mockExecute.mockResolvedValue({
|
||||
code: '10000',
|
||||
msg: 'Success',
|
||||
trade_no: '2026030500001',
|
||||
});
|
||||
it('should close payment by out_trade_no', async () => {
|
||||
mockExecute.mockResolvedValue({ code: '10000', msg: 'Success' });
|
||||
|
||||
await provider.cancelPayment('order-close');
|
||||
|
||||
await provider.cancelPayment('order-001');
|
||||
expect(mockExecute).toHaveBeenCalledWith('alipay.trade.close', {
|
||||
out_trade_no: 'order-001',
|
||||
out_trade_no: 'order-close',
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore ACQ.TRADE_NOT_EXIST when closing payment', async () => {
|
||||
mockExecute.mockRejectedValue(new Error('Alipay API error: [ACQ.TRADE_NOT_EXIST] 交易不存在'));
|
||||
|
||||
await expect(provider.cancelPayment('order-close-missing')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,7 +34,6 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(typeof sign).toBe('string');
|
||||
// base64 格式
|
||||
expect(() => Buffer.from(sign, 'base64')).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -44,16 +43,15 @@ describe('Alipay RSA2 Sign', () => {
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out sign and sign_type fields', () => {
|
||||
it('should filter out sign field but keep sign_type in request signing', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, privateKey);
|
||||
const sign2 = generateSign(paramsWithSign, privateKey);
|
||||
expect(sign1).toBe(sign2);
|
||||
|
||||
// sign_type should also be excluded from signing (per Alipay spec)
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'RSA2' };
|
||||
const sign3 = generateSign(paramsWithSignType, privateKey);
|
||||
expect(sign3).toBe(sign1);
|
||||
expect(sign3).not.toBe(sign1);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
@@ -113,5 +111,35 @@ describe('Alipay RSA2 Sign', () => {
|
||||
const valid = verifySign(testParams, barePublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with private key using literal \\n escapes', () => {
|
||||
const escapedPrivateKey = privateKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, escapedPrivateKey);
|
||||
const valid = verifySign(testParams, publicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with public key using literal \\n escapes', () => {
|
||||
const escapedPublicKey = publicKey.replace(/\n/g, '\\n');
|
||||
const sign = generateSign(testParams, privateKey);
|
||||
const valid = verifySign(testParams, escapedPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with CRLF-formatted PEM keys', () => {
|
||||
const crlfPrivateKey = privateKey.replace(/\n/g, '\r\n');
|
||||
const crlfPublicKey = publicKey.replace(/\n/g, '\r\n');
|
||||
const sign = generateSign(testParams, crlfPrivateKey);
|
||||
const valid = verifySign(testParams, crlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should work with literal \\r\\n escapes in PEM keys', () => {
|
||||
const escapedCrlfPrivateKey = privateKey.replace(/\n/g, '\\r\\n');
|
||||
const escapedCrlfPublicKey = publicKey.replace(/\n/g, '\\r\\n');
|
||||
const sign = generateSign(testParams, escapedCrlfPrivateKey);
|
||||
const valid = verifySign(testParams, escapedCrlfPublicKey, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
39
src/__tests__/lib/order/status-access.test.ts
Normal file
39
src/__tests__/lib/order/status-access.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: () => ({
|
||||
ADMIN_TOKEN: 'test-admin-token',
|
||||
}),
|
||||
}));
|
||||
|
||||
import {
|
||||
ORDER_STATUS_ACCESS_QUERY_KEY,
|
||||
buildOrderResultUrl,
|
||||
createOrderStatusAccessToken,
|
||||
verifyOrderStatusAccessToken,
|
||||
} from '@/lib/order/status-access';
|
||||
|
||||
describe('order status access token helpers', () => {
|
||||
it('creates and verifies a token bound to the order id', () => {
|
||||
const token = createOrderStatusAccessToken('order-001');
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-001', token)).toBe(true);
|
||||
expect(verifyOrderStatusAccessToken('order-002', token)).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing or malformed tokens', () => {
|
||||
expect(verifyOrderStatusAccessToken('order-001', null)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', undefined)).toBe(false);
|
||||
expect(verifyOrderStatusAccessToken('order-001', 'short')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds a result url with order id and access token', () => {
|
||||
const url = new URL(buildOrderResultUrl('https://pay.example.com', 'order-009'));
|
||||
expect(url.origin + url.pathname).toBe('https://pay.example.com/pay/result');
|
||||
expect(url.searchParams.get('order_id')).toBe('order-009');
|
||||
const token = url.searchParams.get(ORDER_STATUS_ACCESS_QUERY_KEY);
|
||||
expect(token).toBeTruthy();
|
||||
expect(verifyOrderStatusAccessToken('order-009', token)).toBe(true);
|
||||
});
|
||||
});
|
||||
66
src/__tests__/lib/order/status.test.ts
Normal file
66
src/__tests__/lib/order/status.test.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ORDER_STATUS } from '@/lib/constants';
|
||||
import { deriveOrderState, getOrderDisplayState } from '@/lib/order/status';
|
||||
|
||||
describe('order status helpers', () => {
|
||||
it('derives paid_pending after successful payment but before recharge completion', () => {
|
||||
const state = deriveOrderState({
|
||||
status: ORDER_STATUS.PAID,
|
||||
paidAt: new Date('2026-03-09T10:00:00Z'),
|
||||
completedAt: null,
|
||||
});
|
||||
|
||||
expect(state).toEqual({
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'paid_pending',
|
||||
});
|
||||
});
|
||||
|
||||
it('maps recharge failure after payment to a payment-success display state', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付成功');
|
||||
expect(display.message).toContain('自动重试');
|
||||
});
|
||||
|
||||
it('maps failed order before payment success to failed display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.FAILED,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'failed',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('支付失败');
|
||||
expect(display.message).toContain('重新发起支付');
|
||||
});
|
||||
|
||||
it('maps completed order to success display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.COMPLETED,
|
||||
paymentSuccess: true,
|
||||
rechargeSuccess: true,
|
||||
rechargeStatus: 'success',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('充值成功');
|
||||
expect(display.icon).toBe('✓');
|
||||
});
|
||||
|
||||
it('maps pending order to waiting-for-payment display', () => {
|
||||
const display = getOrderDisplayState({
|
||||
status: ORDER_STATUS.PENDING,
|
||||
paymentSuccess: false,
|
||||
rechargeSuccess: false,
|
||||
rechargeStatus: 'not_paid',
|
||||
});
|
||||
|
||||
expect(display.label).toBe('等待支付');
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ data: mockUser }),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const user = await getUser(1);
|
||||
expect(user.username).toBe('testuser');
|
||||
@@ -37,7 +37,7 @@ describe('Sub2API Client', () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
await expect(getUser(999)).rejects.toThrow('USER_NOT_FOUND');
|
||||
});
|
||||
@@ -57,24 +57,50 @@ describe('Sub2API Client', () => {
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_test123', 100, 1, 'test notes');
|
||||
expect(result.code).toBe('s2p_test123');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(fetchCall[0]).toContain('/redeem-codes/create-and-redeem');
|
||||
const headers = fetchCall[1].headers;
|
||||
const headers = fetchCall[1].headers as Record<string, string>;
|
||||
expect(headers['Idempotency-Key']).toBe('sub2apipay:recharge:s2p_test123');
|
||||
});
|
||||
|
||||
it('createAndRedeem should retry once on timeout', async () => {
|
||||
const timeoutError = Object.assign(new Error('timed out'), { name: 'TimeoutError' });
|
||||
global.fetch = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(timeoutError)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
redeem_code: {
|
||||
id: 2,
|
||||
code: 's2p_retry',
|
||||
type: 'balance',
|
||||
value: 88,
|
||||
status: 'used',
|
||||
used_by: 1,
|
||||
},
|
||||
}),
|
||||
}) as typeof fetch;
|
||||
|
||||
const result = await createAndRedeem('s2p_retry', 88, 1, 'retry notes');
|
||||
|
||||
expect(result.code).toBe('s2p_retry');
|
||||
expect((fetch as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('subtractBalance should send subtract request', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) });
|
||||
global.fetch = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve({}) }) as typeof fetch;
|
||||
|
||||
await subtractBalance(1, 50, 'refund', 'idempotency-key-1');
|
||||
|
||||
const fetchCall = (fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = JSON.parse(fetchCall[1].body);
|
||||
const body = JSON.parse(fetchCall[1].body as string);
|
||||
expect(body.operation).toBe('subtract');
|
||||
expect(body.amount).toBe(50);
|
||||
});
|
||||
|
||||
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
18
src/__tests__/lib/time/biz-day.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getBizDayStartUTC, getNextBizDayStartUTC, toBizDateStr } from '@/lib/time/biz-day';
|
||||
|
||||
describe('biz-day helpers', () => {
|
||||
it('formats business date in Asia/Shanghai timezone', () => {
|
||||
expect(toBizDateStr(new Date('2026-03-09T15:59:59.000Z'))).toBe('2026-03-09');
|
||||
expect(toBizDateStr(new Date('2026-03-09T16:00:00.000Z'))).toBe('2026-03-10');
|
||||
});
|
||||
|
||||
it('returns business day start in UTC', () => {
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T15:59:59.000Z')).toISOString()).toBe('2026-03-08T16:00:00.000Z');
|
||||
expect(getBizDayStartUTC(new Date('2026-03-09T16:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('returns next business day start in UTC', () => {
|
||||
expect(getNextBizDayStartUTC(new Date('2026-03-09T12:00:00.000Z')).toISOString()).toBe('2026-03-09T16:00:00.000Z');
|
||||
});
|
||||
});
|
||||
@@ -241,11 +241,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
provider = new AlipayProvider();
|
||||
});
|
||||
|
||||
it('PC: uses alipay.trade.page.pay, returns payUrl only (no qrCode)', async () => {
|
||||
mockAlipayPageExecute.mockReturnValue(
|
||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=xxx',
|
||||
);
|
||||
|
||||
it('PC: returns service short-link payUrl and qrCode', async () => {
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-ali-001',
|
||||
amount: 100,
|
||||
@@ -257,20 +253,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.tradeNo).toBe('order-ali-001');
|
||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
||||
expect(result.qrCode).toBeUndefined();
|
||||
expect(result.payUrl).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(result.qrCode).toBe('https://pay.example.com/pay/order-ali-001');
|
||||
expect(mockAlipayPageExecute).not.toHaveBeenCalled();
|
||||
|
||||
// Verify pageExecute was called with PC method
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
product_code: 'FAST_INSTANT_TRADE_PAY',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
method: 'alipay.trade.page.pay',
|
||||
}),
|
||||
);
|
||||
|
||||
// PC + payUrl only (no qrCode) => shouldAutoRedirect = true (redirect to Alipay cashier page)
|
||||
expect(
|
||||
shouldAutoRedirect({
|
||||
expired: false,
|
||||
@@ -279,7 +265,7 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
qrCode: result.qrCode,
|
||||
isMobile: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('Mobile: uses alipay.trade.wap.pay, returns payUrl', async () => {
|
||||
@@ -323,15 +309,10 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('Mobile: falls back to PC page.pay when wap.pay throws', async () => {
|
||||
// First call (wap.pay) throws, second call (page.pay) succeeds
|
||||
mockAlipayPageExecute
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error('WAP pay not available');
|
||||
})
|
||||
.mockReturnValueOnce(
|
||||
'https://openapi.alipay.com/gateway.do?method=alipay.trade.page.pay&sign=fallback',
|
||||
);
|
||||
it('Mobile: surfaces wap.pay creation errors', async () => {
|
||||
mockAlipayPageExecute.mockImplementationOnce(() => {
|
||||
throw new Error('WAP pay not available');
|
||||
});
|
||||
|
||||
const request: CreatePaymentRequest = {
|
||||
orderId: 'order-ali-003',
|
||||
@@ -341,21 +322,12 @@ describe('Payment Flow - PC/Mobile, QR/Redirect', () => {
|
||||
isMobile: true,
|
||||
};
|
||||
|
||||
const result = await provider.createPayment(request);
|
||||
|
||||
expect(result.payUrl).toContain('alipay.trade.page.pay');
|
||||
// pageExecute was called twice: first wap.pay (failed), then page.pay
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(2);
|
||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
await expect(provider.createPayment(request)).rejects.toThrow('WAP pay not available');
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledTimes(1);
|
||||
expect(mockAlipayPageExecute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ product_code: 'QUICK_WAP_WAY' }),
|
||||
expect.objectContaining({ method: 'alipay.trade.wap.pay' }),
|
||||
);
|
||||
expect(mockAlipayPageExecute).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ product_code: 'FAST_INSTANT_TRADE_PAY' }),
|
||||
expect.objectContaining({ method: 'alipay.trade.page.pay' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('alipay_direct is in REDIRECT_PAYMENT_TYPES', () => {
|
||||
|
||||
Reference in New Issue
Block a user