feat: 订阅管理增强、商品名称配置、余额充值开关
- R1: 用户订阅搜索改为模糊关键词(邮箱/用户名/备注/APIKey) - R2: "分组状态"列名改为"Sub2API 状态" - R3: 订阅套餐可配置支付商品名称(productName) - R4: 订阅订单校验 subscription_type 必须为 subscription - R5: 渠道管理配置余额充值商品名前缀/后缀 - R6: 渠道管理可关闭余额充值,前端隐藏入口,API 拒绝 - R7: 所有入口关闭时显示"入口被管理员关闭"提示 - fix: easy-pay client 测试 mock 方式修复(vi.fn + 参数快照)
This commit is contained in:
349
src/__tests__/lib/easy-pay/client.test.ts
Normal file
349
src/__tests__/lib/easy-pay/client.test.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const { mockGetEnv } = vi.hoisted(() => ({
|
||||
mockGetEnv: vi.fn(() => ({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: undefined,
|
||||
EASY_PAY_CID_ALIPAY: undefined,
|
||||
EASY_PAY_CID_WXPAY: undefined,
|
||||
})),
|
||||
}));
|
||||
vi.mock('@/lib/config', () => ({
|
||||
getEnv: mockGetEnv,
|
||||
}));
|
||||
|
||||
const { mockGenerateSign, signCallSnapshots } = vi.hoisted(() => {
|
||||
const snapshots: Record<string, string>[][] = [];
|
||||
return {
|
||||
signCallSnapshots: snapshots,
|
||||
mockGenerateSign: vi.fn((...args: unknown[]) => {
|
||||
// Snapshot params at call time (before caller mutates the object)
|
||||
snapshots.push(args.map((a) => (typeof a === 'object' && a ? { ...a } : a)) as Record<string, string>[]);
|
||||
return 'mocked-sign-value';
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock('@/lib/easy-pay/sign', () => ({
|
||||
generateSign: mockGenerateSign,
|
||||
}));
|
||||
|
||||
import { createPayment, queryOrder } from '@/lib/easy-pay/client';
|
||||
|
||||
describe('EasyPay client', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
signCallSnapshots.length = 0;
|
||||
});
|
||||
|
||||
describe('createPayment', () => {
|
||||
it('should build correct params and POST to mapi.php', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000001',
|
||||
payurl: 'https://pay.example.com/pay/EP20260313000001',
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await createPayment({
|
||||
outTradeNo: 'order-001',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Test Product',
|
||||
});
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.trade_no).toBe('EP20260313000001');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toBe('https://pay.example.com/mapi.php');
|
||||
expect(init.method).toBe('POST');
|
||||
expect(init.headers).toEqual({ 'Content-Type': 'application/x-www-form-urlencoded' });
|
||||
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('pid')).toBe('1001');
|
||||
expect(body.get('type')).toBe('alipay');
|
||||
expect(body.get('out_trade_no')).toBe('order-001');
|
||||
expect(body.get('money')).toBe('10.00');
|
||||
expect(body.get('name')).toBe('Test Product');
|
||||
expect(body.get('clientip')).toBe('127.0.0.1');
|
||||
expect(body.get('notify_url')).toBe('https://pay.example.com/api/easy-pay/notify');
|
||||
expect(body.get('return_url')).toBe('https://pay.example.com/pay/result');
|
||||
expect(body.get('sign')).toBe('mocked-sign-value');
|
||||
expect(body.get('sign_type')).toBe('MD5');
|
||||
});
|
||||
|
||||
it('should call generateSign with correct params (without sign/sign_type)', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-002',
|
||||
amount: '20.00',
|
||||
paymentType: 'wxpay',
|
||||
clientIp: '10.0.0.1',
|
||||
productName: 'Another Product',
|
||||
});
|
||||
|
||||
expect(mockGenerateSign).toHaveBeenCalledTimes(1);
|
||||
const [signParams, pkey] = signCallSnapshots[signCallSnapshots.length - 1] as [Record<string, string>, string];
|
||||
expect(pkey).toBe('test-merchant-secret-key');
|
||||
// sign and sign_type should not be in the params passed to generateSign
|
||||
expect(signParams).not.toHaveProperty('sign');
|
||||
expect(signParams).not.toHaveProperty('sign_type');
|
||||
expect(signParams.type).toBe('wxpay');
|
||||
});
|
||||
|
||||
it('should throw when API returns code !== 1', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ code: -1, msg: 'Invalid parameter' }),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
outTradeNo: 'order-003',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
}),
|
||||
).rejects.toThrow('EasyPay create payment failed: Invalid parameter');
|
||||
});
|
||||
|
||||
it('should throw with "unknown error" when msg is absent', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0 }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(
|
||||
createPayment({
|
||||
outTradeNo: 'order-004',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
}),
|
||||
).rejects.toThrow('EasyPay create payment failed: unknown error');
|
||||
});
|
||||
|
||||
it('should not include cid when no CID env vars are set', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-005',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.has('cid')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPayment CID routing', () => {
|
||||
it('should use EASY_PAY_CID_ALIPAY for alipay payment type', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: '200',
|
||||
EASY_PAY_CID_WXPAY: '300',
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-1',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('200');
|
||||
});
|
||||
|
||||
it('should use EASY_PAY_CID_WXPAY for wxpay payment type', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: '200',
|
||||
EASY_PAY_CID_WXPAY: '300',
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-2',
|
||||
amount: '10.00',
|
||||
paymentType: 'wxpay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('300');
|
||||
});
|
||||
|
||||
it('should fall back to EASY_PAY_CID when channel-specific CID is not set', async () => {
|
||||
mockGetEnv.mockReturnValue({
|
||||
EASY_PAY_PID: '1001',
|
||||
EASY_PAY_PKEY: 'test-merchant-secret-key',
|
||||
EASY_PAY_API_BASE: 'https://pay.example.com',
|
||||
EASY_PAY_NOTIFY_URL: 'https://pay.example.com/api/easy-pay/notify',
|
||||
EASY_PAY_RETURN_URL: 'https://pay.example.com/pay/result',
|
||||
EASY_PAY_CID: '100',
|
||||
EASY_PAY_CID_ALIPAY: undefined,
|
||||
EASY_PAY_CID_WXPAY: undefined,
|
||||
});
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 1, trade_no: 'EP001' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await createPayment({
|
||||
outTradeNo: 'order-cid-3',
|
||||
amount: '10.00',
|
||||
paymentType: 'alipay',
|
||||
clientIp: '127.0.0.1',
|
||||
productName: 'Product',
|
||||
});
|
||||
|
||||
const [, init] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const body = new URLSearchParams(init.body as string);
|
||||
expect(body.get('cid')).toBe('100');
|
||||
});
|
||||
});
|
||||
|
||||
describe('queryOrder', () => {
|
||||
it('should call GET api.php with correct query parameters', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000001',
|
||||
out_trade_no: 'order-001',
|
||||
type: 'alipay',
|
||||
pid: '1001',
|
||||
addtime: '2026-03-13 10:00:00',
|
||||
endtime: '2026-03-13 10:01:00',
|
||||
name: 'Test Product',
|
||||
money: '10.00',
|
||||
status: 1,
|
||||
}),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await queryOrder('order-001');
|
||||
|
||||
expect(result.code).toBe(1);
|
||||
expect(result.trade_no).toBe('EP20260313000001');
|
||||
expect(result.status).toBe(1);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(url).toContain('https://pay.example.com/api.php');
|
||||
expect(url).toContain('act=order');
|
||||
expect(url).toContain('pid=1001');
|
||||
expect(url).toContain('key=test-merchant-secret-key');
|
||||
expect(url).toContain('out_trade_no=order-001');
|
||||
});
|
||||
|
||||
it('should throw when API returns code !== 1', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({ code: -1, msg: 'Order not found' }),
|
||||
{ headers: { 'content-type': 'application/json' } },
|
||||
),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(queryOrder('nonexistent-order')).rejects.toThrow(
|
||||
'EasyPay query order failed: Order not found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw with "unknown error" when msg is absent', async () => {
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify({ code: 0 }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
await expect(queryOrder('order-err')).rejects.toThrow(
|
||||
'EasyPay query order failed: unknown error',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse all response fields correctly', async () => {
|
||||
const mockResponse = {
|
||||
code: 1,
|
||||
trade_no: 'EP20260313000002',
|
||||
out_trade_no: 'order-010',
|
||||
type: 'wxpay',
|
||||
pid: '1001',
|
||||
addtime: '2026-03-13 12:00:00',
|
||||
endtime: '2026-03-13 12:05:00',
|
||||
name: 'Premium Plan',
|
||||
money: '99.00',
|
||||
status: 1,
|
||||
param: 'custom-param',
|
||||
buyer: 'buyer@example.com',
|
||||
};
|
||||
|
||||
global.fetch = vi.fn().mockResolvedValue(
|
||||
new Response(JSON.stringify(mockResponse), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
) as typeof fetch;
|
||||
|
||||
const result = await queryOrder('order-010');
|
||||
|
||||
expect(result).toEqual(mockResponse);
|
||||
});
|
||||
});
|
||||
});
|
||||
131
src/__tests__/lib/easy-pay/sign.test.ts
Normal file
131
src/__tests__/lib/easy-pay/sign.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import crypto from 'crypto';
|
||||
import { generateSign, verifySign } from '@/lib/easy-pay/sign';
|
||||
|
||||
const TEST_PKEY = 'test-merchant-secret-key';
|
||||
|
||||
describe('EasyPay MD5 Sign', () => {
|
||||
const testParams: Record<string, string> = {
|
||||
pid: '1001',
|
||||
type: 'alipay',
|
||||
out_trade_no: 'order-001',
|
||||
notify_url: 'https://pay.example.com/api/easy-pay/notify',
|
||||
return_url: 'https://pay.example.com/pay/result',
|
||||
name: 'Test Product',
|
||||
money: '10.00',
|
||||
clientip: '127.0.0.1',
|
||||
};
|
||||
|
||||
describe('generateSign', () => {
|
||||
it('should generate a valid MD5 hex string', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
expect(sign).toBeTruthy();
|
||||
expect(sign).toMatch(/^[0-9a-f]{32}$/);
|
||||
});
|
||||
|
||||
it('should produce consistent signatures for same input', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(testParams, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should sort parameters alphabetically', () => {
|
||||
const reversed: Record<string, string> = {};
|
||||
const keys = Object.keys(testParams).reverse();
|
||||
for (const key of keys) {
|
||||
reversed[key] = testParams[key];
|
||||
}
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(reversed, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should filter out empty values', () => {
|
||||
const paramsWithEmpty = { ...testParams, empty_field: '' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithEmpty, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should exclude sign field from signing', () => {
|
||||
const paramsWithSign = { ...testParams, sign: 'old_sign' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithSign, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should exclude sign_type field from signing', () => {
|
||||
const paramsWithSignType = { ...testParams, sign_type: 'MD5' };
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(paramsWithSignType, TEST_PKEY);
|
||||
expect(sign1).toBe(sign2);
|
||||
});
|
||||
|
||||
it('should produce correct MD5 hash for known input', () => {
|
||||
// Manually compute expected: sorted keys → query string → append pkey → MD5
|
||||
const sorted = Object.entries(testParams)
|
||||
.filter(([, v]) => v !== '')
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
const queryString = sorted.map(([k, v]) => `${k}=${v}`).join('&');
|
||||
const expected = crypto
|
||||
.createHash('md5')
|
||||
.update(queryString + TEST_PKEY)
|
||||
.digest('hex');
|
||||
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
expect(sign).toBe(expected);
|
||||
});
|
||||
|
||||
it('should produce different signatures for different pkeys', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const sign2 = generateSign(testParams, 'different-key');
|
||||
expect(sign1).not.toBe(sign2);
|
||||
});
|
||||
|
||||
it('should produce different signatures for different params', () => {
|
||||
const sign1 = generateSign(testParams, TEST_PKEY);
|
||||
const modified = { ...testParams, money: '99.99' };
|
||||
const sign2 = generateSign(modified, TEST_PKEY);
|
||||
expect(sign1).not.toBe(sign2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('verifySign', () => {
|
||||
it('should return true for a valid signature', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const valid = verifySign(testParams, TEST_PKEY, sign);
|
||||
expect(valid).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for an invalid signature', () => {
|
||||
const valid = verifySign(testParams, TEST_PKEY, 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for tampered params', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const tampered = { ...testParams, money: '999.99' };
|
||||
const valid = verifySign(tampered, TEST_PKEY, sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for wrong pkey', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
const valid = verifySign(testParams, 'wrong-key', sign);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when sign length differs (timing-safe guard)', () => {
|
||||
const valid = verifySign(testParams, TEST_PKEY, 'short');
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
|
||||
it('should use timing-safe comparison (same length, different content)', () => {
|
||||
const sign = generateSign(testParams, TEST_PKEY);
|
||||
// Flip the first character to create a same-length but different sign
|
||||
const flipped = (sign[0] === 'a' ? 'b' : 'a') + sign.slice(1);
|
||||
const valid = verifySign(testParams, TEST_PKEY, flipped);
|
||||
expect(valid).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user