Files
lindyson-ai/apps/api/tests/paymentAttemptService.test.ts
Juno ChowandClaude Fable 5.1 f665082dad chore: 建立产品仓库,完成开源前的敏感信息清理
前端(Vite + React)、API/worker(Fastify + BullMQ)、api-contracts 契约包、
迁移脚本、测试与自托管 docker-compose 示例。

开源准备:运维脚本与机器信息已拆到私有仓库;公司名、备案号、隐私邮箱改读
publicCompliance.local.json(仓库只保留占位模板);站点地址与客服二维码改为
VITE_ 环境变量;fixtures 中的真实用户标识已匿名化;三个 package.json 标注
Apache-2.0。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-16 02:10:46 +08:00

360 lines
12 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
poolQuery: vi.fn(),
withTransaction: vi.fn(),
createPaymentOrder: vi.fn(),
normalizePaymentLaunch: vi.fn(),
queryPaymentOrder: vi.fn(),
getPaymentChannelCredentialsByRevision: vi.fn(),
markOrderPaid: vi.fn(),
markOrderPaymentTerminalFromClaim: vi.fn(),
}));
vi.mock('../src/config/env.js', () => ({
env: {
PAYMENT_CONFIG_ENCRYPTION_KEY: Buffer.alloc(32, 7).toString('base64'),
PAYMENT_ORDER_EXPIRES_SECONDS: 900,
},
}));
vi.mock('../src/db/pool.js', () => ({
pool: { query: mocks.poolQuery },
withTransaction: mocks.withTransaction,
}));
vi.mock('../src/payments/jeepayService.js', () => ({
createPaymentOrder: mocks.createPaymentOrder,
normalizePaymentLaunch: mocks.normalizePaymentLaunch,
queryPaymentOrder: mocks.queryPaymentOrder,
}));
vi.mock('../src/payments/paymentChannelService.js', () => ({
getPaymentChannelCredentialsByRevision: mocks.getPaymentChannelCredentialsByRevision,
}));
vi.mock('../src/domain/ordersService.js', () => ({
markOrderPaid: mocks.markOrderPaid,
markOrderPaymentTerminalFromClaim: mocks.markOrderPaymentTerminalFromClaim,
}));
import {
createPaymentRetryAttempt,
ensurePaymentAttempt,
getCurrentPaymentAttempt,
markPaymentAttemptReady,
markPaymentAttemptManualReviewFromClaim,
markPaymentAttemptManualReviewFromProviderEvent,
processDuePaymentAttempts,
} from '../src/payments/paymentAttemptService.js';
describe('支付 attempt 状态机', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getPaymentChannelCredentialsByRevision.mockResolvedValue(channelConfig());
mocks.markOrderPaid.mockResolvedValue({ status: 'paid' });
mocks.markOrderPaymentTerminalFromClaim.mockResolvedValue({ status: 'failed' });
});
it('二维码按 attempt ID 作为 AAD 加密,重放同一 attempt 可恢复原 launch', async () => {
let saved: ReturnType<typeof attemptRow> | undefined;
mocks.poolQuery.mockImplementationOnce(async (_sql: unknown, values?: unknown[]) => {
saved = attemptRow({
status: 'ready',
provider_order_id: 'P100',
launch_ciphertext: values?.[3],
launch_iv: values?.[4],
launch_tag: values?.[5],
});
return result([saved]);
});
const launch = { surface: 'pc' as const, type: 'qr' as const, codeUrl: 'https://openapi.alipay.com/pay?id=P100' };
await expect(markPaymentAttemptReady(claim(), 'P100', launch)).resolves.toMatchObject({ launch });
mocks.poolQuery.mockResolvedValueOnce(result([saved!]));
await expect(getCurrentPaymentAttempt(ORDER_ID, 'user-1')).resolves.toMatchObject({ launch });
});
it('旧 lease token 更新零行时抛出 stale claim,不能写终态', async () => {
mocks.poolQuery.mockResolvedValueOnce(result([]));
await expect(markPaymentAttemptReady(
claim(),
'P100',
{ surface: 'pc', type: 'qr', codeUrl: 'https://openapi.alipay.com/pay?id=P100' },
)).rejects.toMatchObject({ code: 'PAYMENT_ATTEMPT_STALE_CLAIM', statusCode: 409 });
});
it('旧 lease token 不能将 attempt 转人工核对', async () => {
mocks.poolQuery.mockResolvedValueOnce(result([]));
await expect(markPaymentAttemptManualReviewFromClaim(
claim(),
'供应商字段冲突',
)).rejects.toMatchObject({ code: 'PAYMENT_ATTEMPT_STALE_CLAIM', statusCode: 409 });
});
it('已支付订单即使当前 attempt 失败也禁止重置为 pending', async () => {
const client = {
query: vi.fn()
.mockResolvedValueOnce(result([{ ...retryOrder(), status: 'paid' }]))
.mockResolvedValueOnce(result([])),
};
mocks.withTransaction.mockImplementation(async callback => callback(client));
await expect(createPaymentRetryAttempt(
ORDER_ID,
'user-1',
'payment-retry-new-key',
channelConfig(),
'https://www.lindyson.cn',
)).rejects.toMatchObject({ code: 'IDEMPOTENCY_CONFLICT', statusCode: 409 });
expect(client.query.mock.calls.some(([sql]) => String(sql).includes('INSERT INTO payment_attempts'))).toBe(false);
});
it('重放相同 retry 幂等键时返回原 attempt,不受订单后续终态影响', async () => {
const duplicate = attemptRow({ status: 'failed', terminal_at: '2026-07-10T06:05:00.000Z' });
const client = {
query: vi.fn()
.mockResolvedValueOnce(result([{ ...retryOrder(), status: 'paid' }]))
.mockResolvedValueOnce(result([duplicate])),
};
mocks.withTransaction.mockImplementation(async callback => callback(client));
await expect(createPaymentRetryAttempt(
ORDER_ID,
'user-1',
duplicate.idempotency_key,
channelConfig(),
'https://www.lindyson.cn',
)).resolves.toMatchObject({ id: duplicate.id, status: 'failed' });
});
it('首次 attempt 将可信请求来源作为不可变快照显式写入', async () => {
const client = {
query: vi.fn(async (sql: unknown, _values?: unknown[]) => {
const query = String(sql);
if (query.includes('JOIN payment_attempts attempt')) return result([]);
if (query.includes('INSERT INTO payment_attempts')) {
return result([attemptRow({ status: 'queued', return_origin: 'https://www.lindyson.cn' })]);
}
return result([]);
}),
};
mocks.withTransaction.mockImplementation(async callback => callback(client));
await ensurePaymentAttempt({
id: ORDER_ID,
amount: 22800,
paymentSurface: 'mobile',
paymentWayCode: 'ALI_WAP',
}, 'payment-create-cn', 'request-hash', channelConfig(), 'https://www.lindyson.cn');
const insertCall = client.query.mock.calls.find(([sql]) => String(sql).includes('INSERT INTO payment_attempts'));
expect(String(insertCall?.[0])).toContain('return_origin');
expect(insertCall?.[1]).toEqual(expect.arrayContaining(['https://www.lindyson.cn']));
});
it('支付重试把本次可信来源写入新 attempt,不继承旧 attempt 来源', async () => {
const client = {
query: vi.fn()
.mockResolvedValueOnce(result([retryOrder()]))
.mockResolvedValueOnce(result([]))
.mockResolvedValueOnce(result([attemptRow({
status: 'failed',
return_origin: 'https://www.lindyson.com',
})]))
.mockResolvedValueOnce(result([attemptRow({
attempt: 2,
status: 'queued',
return_origin: 'https://www.lindyson.cn',
})]))
.mockResolvedValueOnce(result([])),
};
mocks.withTransaction.mockImplementation(async callback => callback(client));
await createPaymentRetryAttempt(
ORDER_ID,
'user-1',
'payment-retry-cn',
channelConfig(),
'https://www.lindyson.cn',
);
const insertCall = client.query.mock.calls.find(([sql]) => String(sql).includes('INSERT INTO payment_attempts'));
expect(String(insertCall?.[0])).toContain('return_origin');
expect(insertCall?.[1]).toEqual(expect.arrayContaining(['https://www.lindyson.cn']));
});
it('事件冲突允许 paid attempt 转人工核对并清除 launch 密文', async () => {
mocks.poolQuery.mockResolvedValueOnce(result([attemptRow({ status: 'manual-review' })]));
await markPaymentAttemptManualReviewFromProviderEvent(
{ query: mocks.poolQuery } as never,
ATTEMPT_ID,
'同事件键载荷冲突',
);
const [sql] = mocks.poolQuery.mock.calls[0]!;
expect(String(sql)).toContain("SET status = 'manual-review'");
expect(String(sql)).toContain('launch_ciphertext = NULL');
});
it('uncertain attempt 恢复时只查单,绝不再次调用创建接口', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([]))
.mockResolvedValueOnce(result([dueAttempt({ previous_status: 'uncertain' })]))
.mockResolvedValueOnce(result([attemptRow({ status: 'uncertain' })]));
mocks.queryPaymentOrder.mockResolvedValue(paymentQuery(1));
await expect(processDuePaymentAttempts(1)).resolves.toEqual({
claimed: 1,
ready: 0,
paid: 0,
failed: 0,
uncertain: 1,
});
expect(mocks.createPaymentOrder).not.toHaveBeenCalled();
expect(mocks.queryPaymentOrder).toHaveBeenCalledWith(channelConfig(), MERCHANT_ORDER_NO);
});
it('queued 创建响应丢失后先查固定商户号,不能进行第二次创建', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([]))
.mockResolvedValueOnce(result([dueAttempt({
previous_status: 'queued',
return_origin: 'https://www.lindyson.cn',
})]))
.mockResolvedValueOnce(result([attemptRow({ status: 'uncertain' })]));
mocks.createPaymentOrder.mockRejectedValue(new Error('response lost'));
mocks.queryPaymentOrder.mockResolvedValue(paymentQuery(1));
await processDuePaymentAttempts(1);
expect(mocks.createPaymentOrder).toHaveBeenCalledOnce();
expect(mocks.createPaymentOrder).toHaveBeenCalledWith(
channelConfig(),
expect.objectContaining({ returnOrigin: 'https://www.lindyson.cn' }),
);
expect(mocks.queryPaymentOrder).toHaveBeenCalledOnce();
expect(mocks.createPaymentOrder.mock.invocationCallOrder[0])
.toBeLessThan(mocks.queryPaymentOrder.mock.invocationCallOrder[0]!);
});
it('worker 确认失败时把当前 lease 传入订单与 attempt 原子终态服务', async () => {
mocks.poolQuery
.mockResolvedValueOnce(result([]))
.mockResolvedValueOnce(result([dueAttempt({ previous_status: 'uncertain' })]));
mocks.queryPaymentOrder.mockResolvedValue(paymentQuery(3));
await expect(processDuePaymentAttempts(1)).resolves.toMatchObject({ claimed: 1, failed: 1 });
expect(mocks.markOrderPaymentTerminalFromClaim).toHaveBeenCalledWith(
ORDER_ID,
claimWithPreviousStatus('uncertain'),
'failed',
'支付未完成',
'P100',
);
});
});
const ORDER_ID = '11111111-1111-4111-8111-111111111111';
const ATTEMPT_ID = '22222222-2222-4222-8222-222222222222';
const REVISION_ID = '33333333-3333-4333-8333-333333333333';
const LEASE_TOKEN = '44444444-4444-4444-8444-444444444444';
const MERCHANT_ORDER_NO = 'ord_123456789012345678901234';
function claim() {
return {
id: ATTEMPT_ID,
orderId: ORDER_ID,
merchantOrderNo: MERCHANT_ORDER_NO,
channelRevisionId: REVISION_ID,
returnOrigin: 'https://www.lindyson.com',
leaseToken: LEASE_TOKEN,
previousStatus: 'queued' as const,
status: 'processing' as const,
};
}
function claimWithPreviousStatus(previousStatus: 'queued' | 'processing' | 'ready' | 'uncertain') {
return { ...claim(), previousStatus };
}
function attemptRow(overrides: Record<string, unknown> = {}) {
return {
id: ATTEMPT_ID,
order_id: ORDER_ID,
attempt: 1,
idempotency_key: 'payment-retry-existing-key',
request_hash: 'request-hash',
merchant_order_no: MERCHANT_ORDER_NO,
channel_revision_id: REVISION_ID,
provider_order_id: null,
amount: 22800,
currency: 'CNY',
surface: 'pc',
way_code: 'ALI_WAP',
return_origin: 'https://www.lindyson.com',
status: 'processing',
launch_ciphertext: null,
launch_iv: null,
launch_tag: null,
attempts: 1,
next_at: null,
lease_token: LEASE_TOKEN,
lease_until: '2026-07-10T06:01:00.000Z',
error: null,
manual_review_reason: null,
created_at: '2026-07-10T06:00:00.000Z',
updated_at: '2026-07-10T06:00:00.000Z',
...overrides,
};
}
function dueAttempt(overrides: Record<string, unknown> = {}) {
return {
...attemptRow(),
previous_status: 'uncertain',
plan_name: '专业版',
billing: 'yearly',
order_status: 'pending',
...overrides,
};
}
function retryOrder() {
return {
id: ORDER_ID,
user_id: 'user-1',
amount: 22800,
payment_surface: 'pc',
payment_way_code: 'ALI_WAP',
status: 'failed',
current_payment_attempt_id: ATTEMPT_ID,
};
}
function channelConfig() {
return {
revisionId: REVISION_ID,
enabled: true,
apiBase: 'https://pay.example.com',
mchNo: 'M100',
appId: 'APP100',
apiKey: 'secret',
hasApiKey: true,
desktopWayCode: 'ALI_WAP' as const,
mobileWayCode: 'ALI_WAP' as const,
updatedAt: '2026-07-10T06:00:00.000Z',
};
}
function paymentQuery(state: number) {
return {
payOrderId: 'P100',
mchOrderNo: MERCHANT_ORDER_NO,
mchNo: 'M100',
appId: 'APP100',
wayCode: 'ALI_WAP',
amount: 22800,
currency: 'CNY',
state,
};
}
function result<T>(rows: T[]) {
return { rows, rowCount: rows.length };
}