a597fcd076
补齐退款事实对比字段与人工复核审计展示 人工复核重放严格校验管理员、原因和错误码 退款占用重试完整匹配管理员与归一化原因 统一退款原因持久化上限并修复刷新恢复 补充前后端回归测试、生成绑定与数据契约文档
766 lines
26 KiB
TypeScript
766 lines
26 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import {
|
|
fireEvent,
|
|
render,
|
|
screen,
|
|
waitFor,
|
|
within,
|
|
} from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
executeAdminRechargeRefund,
|
|
isAdminApiError,
|
|
listAdminRechargeOrders,
|
|
previewAdminRechargeRefund,
|
|
registerAdminRechargeRefund,
|
|
resolveAdminRechargeRefundManualReview,
|
|
} from '../api/adminApiClient';
|
|
import type {
|
|
AdminRechargeOrderEntryPayload,
|
|
AdminRechargeRefundActionResponse,
|
|
AdminRechargeRefundPayload,
|
|
AdminRechargeRefundPreviewResponse,
|
|
} from '../api/adminApiTypes';
|
|
import { AdminRechargeOrderPage } from './AdminRechargeOrderPage';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
executeAdminRechargeRefund: vi.fn(),
|
|
formatAdminApiError: vi.fn((error: unknown) =>
|
|
error instanceof Error ? error.message : '请求失败',
|
|
),
|
|
isAdminApiError: vi.fn(() => false),
|
|
listAdminRechargeOrders: vi.fn(),
|
|
previewAdminRechargeRefund: vi.fn(),
|
|
registerAdminRechargeRefund: vi.fn(),
|
|
resolveAdminRechargeRefundManualReview: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../components/AdminUserReferenceButton', () => ({
|
|
AdminUserReferenceButton: () => (
|
|
<button aria-label="查看用户信息" type="button" />
|
|
),
|
|
}));
|
|
|
|
vi.mock('../components/useAdminWriteConfirm', () => ({
|
|
useAdminWriteConfirm: () => ({
|
|
confirmDialog: null,
|
|
confirmWrite: vi.fn(() => Promise.resolve(true)),
|
|
}),
|
|
}));
|
|
|
|
const baseOrder: AdminRechargeOrderEntryPayload = {
|
|
orderId: 'order-1001',
|
|
userId: 'user-1',
|
|
user: {
|
|
userId: 'user-1',
|
|
publicUserCode: 'TN1001',
|
|
displayName: '测试用户',
|
|
avatarUrl: null,
|
|
},
|
|
productId: 'points_60',
|
|
productTitle: '60泥点',
|
|
productKind: 'points',
|
|
amountCents: 600,
|
|
status: 'paid',
|
|
paymentChannel: 'wechat_native',
|
|
paidAtMicros: 1_720_000_000_000_000,
|
|
providerTransactionId: 'wx-transaction-1',
|
|
createdAtMicros: 1_720_000_000_000_000,
|
|
pointsDelta: 60,
|
|
cumulativeSuccessRefundCents: 0,
|
|
targetRecoveryPoints: 0,
|
|
recoveredPoints: 0,
|
|
unrecoveredPoints: 0,
|
|
recoveryStatus: null,
|
|
wallet: {
|
|
userId: 'user-1',
|
|
totalBalance: 60,
|
|
spendableBalance: 60,
|
|
dailyFreePoints: 0,
|
|
membershipLimitedPoints: 0,
|
|
permanentPoints: 60,
|
|
heldPoints: 0,
|
|
refundDebtPoints: 0,
|
|
manualFrozen: false,
|
|
refundDebtFrozen: false,
|
|
walletFrozen: false,
|
|
manualRestriction: null,
|
|
},
|
|
refunds: [],
|
|
activeHold: null,
|
|
remainingRefundableCents: 600,
|
|
refundEligible: true,
|
|
refundBlockReasonCode: null,
|
|
};
|
|
|
|
const allowedPreview: AdminRechargeRefundPreviewResponse = {
|
|
order: baseOrder,
|
|
paymentCheck: {
|
|
verified: true,
|
|
tradeState: 'SUCCESS',
|
|
transactionId: 'wx-transaction-1',
|
|
amountTotalCents: 600,
|
|
knownRefundsRefreshed: 0,
|
|
},
|
|
refundAmountCents: 300,
|
|
incrementalRecoveryPoints: 30,
|
|
remainingRefundableCents: 600,
|
|
canSubmit: true,
|
|
blockReasonCode: null,
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
sessionStorage.clear();
|
|
vi.mocked(isAdminApiError).mockReturnValue(false);
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [baseOrder],
|
|
});
|
|
vi.mocked(previewAdminRechargeRefund).mockResolvedValue(allowedPreview);
|
|
vi.mocked(executeAdminRechargeRefund).mockResolvedValue(actionFor(baseOrder));
|
|
vi.mocked(registerAdminRechargeRefund).mockResolvedValue(
|
|
actionFor(baseOrder),
|
|
);
|
|
vi.mocked(resolveAdminRechargeRefundManualReview).mockResolvedValue(
|
|
actionFor(baseOrder),
|
|
);
|
|
});
|
|
|
|
test('充值订单查询传递全部筛选字段', async () => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('order-1001');
|
|
|
|
await user.type(screen.getByRole('textbox', { name: '订单号' }), 'order-x');
|
|
await user.type(
|
|
screen.getByRole('textbox', { name: '微信交易单号' }),
|
|
'wx-x',
|
|
);
|
|
await user.type(screen.getByRole('textbox', { name: '陶泥号' }), 'TNX');
|
|
await user.selectOptions(screen.getByLabelText('支付渠道'), 'wechat_native');
|
|
await user.selectOptions(screen.getByLabelText('订单状态'), 'paid');
|
|
fireEvent.change(screen.getByLabelText('创建时间起'), {
|
|
target: { value: '2026-07-01T08:30' },
|
|
});
|
|
fireEvent.change(screen.getByLabelText('创建时间止'), {
|
|
target: { value: '2026-07-13T18:45' },
|
|
});
|
|
await user.clear(screen.getByLabelText('条数'));
|
|
await user.type(screen.getByLabelText('条数'), '50');
|
|
await user.click(screen.getByRole('button', { name: '查询' }));
|
|
|
|
await waitFor(() => {
|
|
expect(listAdminRechargeOrders).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
expect.objectContaining({
|
|
orderId: 'order-x',
|
|
providerTransactionId: 'wx-x',
|
|
userId: '',
|
|
publicUserCode: 'TNX',
|
|
paymentChannel: 'wechat_native',
|
|
status: 'paid',
|
|
createdAfter: new Date('2026-07-01T08:30').toISOString(),
|
|
createdBefore: new Date('2026-07-13T18:45').toISOString(),
|
|
limit: 50,
|
|
}),
|
|
);
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
['wechat_mp', '微信小程序'],
|
|
['wechat_mp_virtual', '微信小程序虚拟支付'],
|
|
])('充值订单可按真实微信渠道 %s 查询', async (paymentChannel) => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('order-1001');
|
|
|
|
await user.selectOptions(screen.getByLabelText('支付渠道'), paymentChannel);
|
|
await user.click(screen.getByRole('button', { name: '查询' }));
|
|
|
|
await waitFor(() => {
|
|
expect(listAdminRechargeOrders).toHaveBeenLastCalledWith(
|
|
'admin-token',
|
|
expect.objectContaining({ paymentChannel }),
|
|
);
|
|
});
|
|
});
|
|
|
|
test('充值订单展示两种微信小程序渠道中文名称', async () => {
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [
|
|
{ ...baseOrder, orderId: 'order-mp', paymentChannel: 'wechat_mp' },
|
|
{
|
|
...baseOrder,
|
|
orderId: 'order-mp-virtual',
|
|
paymentChannel: 'wechat_mp_virtual',
|
|
},
|
|
],
|
|
});
|
|
renderPage();
|
|
|
|
const mpRow = (await screen.findByText('order-mp')).closest('tr');
|
|
const virtualRow = screen.getByText('order-mp-virtual').closest('tr');
|
|
expect(mpRow && within(mpRow).getByText('微信小程序')).toBeTruthy();
|
|
expect(
|
|
virtualRow && within(virtualRow).getByText('微信小程序虚拟支付'),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('修改部分退款金额会作废已有预检', async () => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '退还剩余金额');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
expect(
|
|
await screen.findByRole('region', { name: '退款预检结果' }),
|
|
).toBeTruthy();
|
|
|
|
await user.clear(screen.getByLabelText('退款金额(元)'));
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '2.00');
|
|
expect(screen.queryByRole('region', { name: '退款预检结果' })).toBeNull();
|
|
expect(
|
|
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
});
|
|
|
|
test('预检被阻止时不会调用退款执行接口', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(previewAdminRechargeRefund).mockResolvedValue({
|
|
...allowedPreview,
|
|
canSubmit: false,
|
|
blockReasonCode: 'insufficient_permanent_points',
|
|
});
|
|
renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
|
|
expect(
|
|
await screen.findByText('用户永久泥点不足,本次不会向微信发起退款'),
|
|
).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
|
|
).toBe(true);
|
|
expect(executeAdminRechargeRefund).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test('已登记的部分退款允许在 REFUND 支付状态下继续退剩余额度', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(previewAdminRechargeRefund).mockResolvedValue({
|
|
...allowedPreview,
|
|
paymentCheck: {
|
|
...allowedPreview.paymentCheck,
|
|
verified: true,
|
|
tradeState: 'REFUND',
|
|
knownRefundsRefreshed: 1,
|
|
},
|
|
remainingRefundableCents: 300,
|
|
});
|
|
renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '退还剩余金额');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
|
|
expect(await screen.findByText('已核验 / REFUND')).toBeTruthy();
|
|
expect(
|
|
screen.getByRole('button', { name: '确认退款' }).hasAttribute('disabled'),
|
|
).toBe(false);
|
|
});
|
|
|
|
test('网络结果不明后当前弹窗直接复用稳定 requestId', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(executeAdminRechargeRefund)
|
|
.mockRejectedValueOnce(new Error('network disconnected'))
|
|
.mockResolvedValueOnce(actionFor(baseOrder));
|
|
renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
|
|
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
|
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
|
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
|
|
.calls[0]?.[1].requestId;
|
|
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '核验支付账单' })
|
|
.hasAttribute('disabled'),
|
|
).toBe(true);
|
|
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
|
|
await waitFor(() =>
|
|
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
|
|
);
|
|
expect(
|
|
vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1].requestId,
|
|
).toBe(firstRequestId);
|
|
expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('刷新后根据 active hold 恢复原退款请求并复用 requestId', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(executeAdminRechargeRefund)
|
|
.mockRejectedValueOnce(new Error('network disconnected'))
|
|
.mockResolvedValueOnce(actionFor(baseOrder));
|
|
const firstRender = renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
|
|
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
|
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
|
|
.calls[0]?.[1].requestId;
|
|
firstRender.unmount();
|
|
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [orderWithActiveHold()],
|
|
});
|
|
renderPage();
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
expect(screen.getByLabelText('退款金额(元)')).toHaveProperty(
|
|
'value',
|
|
'3.00',
|
|
);
|
|
expect(screen.getByLabelText('退款原因')).toHaveProperty('value', '用户申请');
|
|
expect(
|
|
screen
|
|
.getByRole('button', { name: '核验支付账单' })
|
|
.hasAttribute('disabled'),
|
|
).toBe(true);
|
|
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
|
|
|
|
await waitFor(() =>
|
|
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
|
|
);
|
|
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
orderId: 'order-1001',
|
|
refundAmountCents: 300,
|
|
reason: '用户申请',
|
|
requestId: firstRequestId,
|
|
}),
|
|
);
|
|
expect(previewAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('超长退款原因按服务端上限持久化并可在刷新后恢复', async () => {
|
|
const user = userEvent.setup();
|
|
const normalizedReason = '退'.repeat(80);
|
|
vi.mocked(executeAdminRechargeRefund)
|
|
.mockRejectedValueOnce(new Error('network disconnected'))
|
|
.mockResolvedValueOnce(actionFor(baseOrder));
|
|
const firstRender = renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), `${normalizedReason}多`);
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
|
|
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
|
const firstRequestId = vi.mocked(executeAdminRechargeRefund).mock
|
|
.calls[0]?.[1].requestId;
|
|
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[0]?.[1].reason).toBe(
|
|
normalizedReason,
|
|
);
|
|
firstRender.unmount();
|
|
|
|
const activeHoldOrder = orderWithActiveHold();
|
|
activeHoldOrder.activeHold = {
|
|
...activeHoldOrder.activeHold!,
|
|
reason: normalizedReason,
|
|
};
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [activeHoldOrder],
|
|
});
|
|
renderPage();
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
expect(screen.getByRole('dialog', { name: '退款处理' })).toBeTruthy();
|
|
expect(screen.getByLabelText('退款原因')).toHaveProperty(
|
|
'value',
|
|
normalizedReason,
|
|
);
|
|
await user.click(screen.getByRole('button', { name: '继续核对退款' }));
|
|
|
|
await waitFor(() =>
|
|
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(2),
|
|
);
|
|
expect(vi.mocked(executeAdminRechargeRefund).mock.calls[1]?.[1]).toEqual(
|
|
expect.objectContaining({
|
|
reason: normalizedReason,
|
|
requestId: firstRequestId,
|
|
}),
|
|
);
|
|
});
|
|
|
|
test('过期退款请求上下文不会恢复 active hold,只开放安全查单', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce(
|
|
new Error('network disconnected'),
|
|
);
|
|
const firstRender = renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
|
|
|
const storageKey = sessionStorage.key(0);
|
|
expect(storageKey).toBeTruthy();
|
|
const contexts = JSON.parse(
|
|
sessionStorage.getItem(storageKey ?? '') ?? '{}',
|
|
) as Record<string, { createdAtMillis: number }>;
|
|
const persistedContext = contexts['order-1001'];
|
|
expect(persistedContext).toBeTruthy();
|
|
if (!persistedContext) {
|
|
throw new Error('退款请求上下文未写入 sessionStorage');
|
|
}
|
|
persistedContext.createdAtMillis = Date.now() - 3 * 60 * 60 * 1_000;
|
|
sessionStorage.setItem(storageKey ?? '', JSON.stringify(contexts));
|
|
firstRender.unmount();
|
|
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [orderWithActiveHold()],
|
|
});
|
|
renderPage();
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy();
|
|
expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull();
|
|
});
|
|
|
|
test('切换管理员会话后不会复用上一会话的退款 requestId', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce(
|
|
new Error('network disconnected'),
|
|
);
|
|
const firstRender = renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
expect(await screen.findByText(/微信侧退款状态未知/)).toBeTruthy();
|
|
firstRender.unmount();
|
|
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [orderWithActiveHold()],
|
|
});
|
|
renderPage('other-admin-token');
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy();
|
|
expect(executeAdminRechargeRefund).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
test('服务端明确拒绝退款后清理恢复上下文', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(isAdminApiError).mockReturnValue(true);
|
|
vi.mocked(executeAdminRechargeRefund).mockRejectedValueOnce(
|
|
new Error('退款前置条件不满足'),
|
|
);
|
|
const firstRender = renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '用户申请');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
expect(await screen.findByText('退款前置条件不满足')).toBeTruthy();
|
|
firstRender.unmount();
|
|
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [orderWithActiveHold()],
|
|
});
|
|
renderPage();
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
expect(screen.getByRole('dialog', { name: '登记商户平台退款' })).toBeTruthy();
|
|
});
|
|
|
|
test('active hold 缺少原 requestId 时只开放预填退款单号的查单登记', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [orderWithActiveHold()],
|
|
});
|
|
renderPage();
|
|
|
|
await user.click(await screen.findByRole('button', { name: '核对退款' }));
|
|
|
|
const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' });
|
|
expect(
|
|
within(dialog).getByRole('textbox', {
|
|
name: '商户退款单号 out_refund_no',
|
|
}),
|
|
).toHaveProperty('value', 'refund-1001');
|
|
expect(screen.queryByRole('dialog', { name: '退款处理' })).toBeNull();
|
|
expect(executeAdminRechargeRefund).not.toHaveBeenCalled();
|
|
|
|
await user.click(within(dialog).getByRole('button', { name: '查询并登记' }));
|
|
await waitFor(() => {
|
|
expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', {
|
|
outRefundNo: 'refund-1001',
|
|
});
|
|
});
|
|
});
|
|
|
|
test.each(['provider_transaction_id_mismatch', 'order_total_mismatch'])(
|
|
'允许处理可确认的退款人工复核错误 %s',
|
|
async (lastErrorCode) => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [
|
|
{
|
|
...baseOrder,
|
|
refunds: [
|
|
manualReviewRefund({
|
|
lastErrorCode,
|
|
providerTransactionId:
|
|
lastErrorCode === 'provider_transaction_id_mismatch'
|
|
? 'wx-transaction-other'
|
|
: 'wx-transaction-1',
|
|
totalCents: lastErrorCode === 'order_total_mismatch' ? 700 : 600,
|
|
}),
|
|
],
|
|
},
|
|
],
|
|
});
|
|
renderPage();
|
|
|
|
await user.click(await screen.findByRole('button', { name: '人工复核' }));
|
|
const dialog = screen.getByRole('dialog', { name: '确认退款人工复核' });
|
|
const submitButton = within(dialog).getByRole('button', {
|
|
name: '确认并追回',
|
|
});
|
|
expect(
|
|
within(dialog).getByText(
|
|
lastErrorCode === 'provider_transaction_id_mismatch'
|
|
? '微信交易单号不一致'
|
|
: '订单总额不一致',
|
|
),
|
|
).toBeTruthy();
|
|
expect(
|
|
within(dialog).getByText('本地微信交易单号').parentElement?.textContent,
|
|
).toContain('wx-transaction-1');
|
|
expect(
|
|
within(dialog).getByText('退款微信交易单号').parentElement?.textContent,
|
|
).toContain(
|
|
lastErrorCode === 'provider_transaction_id_mismatch'
|
|
? 'wx-transaction-other'
|
|
: 'wx-transaction-1',
|
|
);
|
|
expect(
|
|
within(dialog).getByText('本地订单总额').parentElement?.textContent,
|
|
).toContain('¥6.00');
|
|
expect(
|
|
within(dialog).getByText('退款订单总额').parentElement?.textContent,
|
|
).toContain(lastErrorCode === 'order_total_mismatch' ? '¥7.00' : '¥6.00');
|
|
expect(submitButton.hasAttribute('disabled')).toBe(true);
|
|
await user.type(
|
|
within(dialog).getByRole('textbox', { name: '人工复核原因' }),
|
|
'已核对微信商户平台原始账单',
|
|
);
|
|
await user.click(submitButton);
|
|
|
|
await waitFor(() => {
|
|
expect(resolveAdminRechargeRefundManualReview).toHaveBeenCalledWith(
|
|
'admin-token',
|
|
{
|
|
outRefundNo: 'refund-manual-1',
|
|
reason: '已核对微信商户平台原始账单',
|
|
expectedErrorCode: lastErrorCode,
|
|
},
|
|
);
|
|
});
|
|
},
|
|
);
|
|
|
|
test('非白名单、已处理和会员退款不显示人工复核按钮', async () => {
|
|
vi.mocked(listAdminRechargeOrders).mockResolvedValue({
|
|
entries: [
|
|
{
|
|
...baseOrder,
|
|
orderId: 'order-non-whitelist',
|
|
refunds: [manualReviewRefund({ lastErrorCode: 'other_error' })],
|
|
},
|
|
{
|
|
...baseOrder,
|
|
orderId: 'order-resolved',
|
|
refunds: [
|
|
manualReviewRefund({
|
|
manualReviewResolvedAtMicros: 1_720_000,
|
|
manualReviewResolvedByAdminUserId: 'admin-1',
|
|
manualReviewResolutionReason: '已核对商户平台',
|
|
manualReviewResolvedErrorCode: 'provider_transaction_id_mismatch',
|
|
}),
|
|
],
|
|
},
|
|
{
|
|
...baseOrder,
|
|
orderId: 'order-membership',
|
|
productKind: 'membership',
|
|
refunds: [manualReviewRefund()],
|
|
},
|
|
],
|
|
});
|
|
renderPage();
|
|
|
|
await screen.findByText('order-membership');
|
|
expect(screen.queryByRole('button', { name: '人工复核' })).toBeNull();
|
|
expect(screen.getByText(/复核 微信交易单号不一致/).textContent).toContain(
|
|
'admin-1',
|
|
);
|
|
expect(screen.getByText(/已核对商户平台/)).toBeTruthy();
|
|
});
|
|
|
|
test('支付侧已退款但泥点不足时持续展示异常欠账与消费限制', async () => {
|
|
const user = userEvent.setup();
|
|
const debtOrder = {
|
|
...baseOrder,
|
|
unrecoveredPoints: 25,
|
|
wallet: {
|
|
...baseOrder.wallet,
|
|
spendableBalance: 0,
|
|
permanentPoints: 0,
|
|
refundDebtPoints: 25,
|
|
refundDebtFrozen: true,
|
|
walletFrozen: true,
|
|
},
|
|
};
|
|
vi.mocked(executeAdminRechargeRefund).mockResolvedValue(actionFor(debtOrder));
|
|
renderPage();
|
|
await openPartialRefund(user);
|
|
await user.type(screen.getByLabelText('退款金额(元)'), '3.00');
|
|
await user.type(screen.getByLabelText('退款原因'), '外部退款追缴');
|
|
await user.click(screen.getByRole('button', { name: '核验支付账单' }));
|
|
await user.click(screen.getByRole('button', { name: '确认退款' }));
|
|
|
|
const banner = await screen.findByText('退款异常欠账 25 泥点');
|
|
expect(banner).toBeTruthy();
|
|
expect(
|
|
screen.getByText(
|
|
'支付侧退款已记录,当前泥点不足,已限制继续消费并进入人工处理。',
|
|
),
|
|
).toBeTruthy();
|
|
});
|
|
|
|
test('商户平台退款登记只提交 outRefundNo', async () => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('order-1001');
|
|
await user.click(screen.getByRole('button', { name: '登记商户退款' }));
|
|
const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' });
|
|
await user.type(
|
|
within(dialog).getByRole('textbox', { name: '商户退款单号 out_refund_no' }),
|
|
'refund-1001',
|
|
);
|
|
await user.click(within(dialog).getByRole('button', { name: '查询并登记' }));
|
|
|
|
await waitFor(() => {
|
|
expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', {
|
|
outRefundNo: 'refund-1001',
|
|
});
|
|
});
|
|
});
|
|
|
|
test('疑似微信 refund_id 的编号交由服务端按真实查单结果判断', async () => {
|
|
const user = userEvent.setup();
|
|
renderPage();
|
|
await screen.findByText('order-1001');
|
|
await user.click(screen.getByRole('button', { name: '登记商户退款' }));
|
|
const dialog = screen.getByRole('dialog', { name: '登记商户平台退款' });
|
|
await user.type(
|
|
within(dialog).getByRole('textbox', { name: '商户退款单号 out_refund_no' }),
|
|
'50000000000000000000000000000',
|
|
);
|
|
await user.click(within(dialog).getByRole('button', { name: '查询并登记' }));
|
|
|
|
await waitFor(() => {
|
|
expect(registerAdminRechargeRefund).toHaveBeenCalledWith('admin-token', {
|
|
outRefundNo: '50000000000000000000000000000',
|
|
});
|
|
});
|
|
});
|
|
|
|
function renderPage(token = 'admin-token') {
|
|
return render(
|
|
<AdminRechargeOrderPage token={token} onUnauthorized={vi.fn()} />,
|
|
);
|
|
}
|
|
|
|
async function openPartialRefund(user: ReturnType<typeof userEvent.setup>) {
|
|
await screen.findByText('order-1001');
|
|
await user.click(screen.getByRole('button', { name: '退款' }));
|
|
await user.click(screen.getByRole('button', { name: '部分退款' }));
|
|
}
|
|
|
|
function actionFor(
|
|
order: AdminRechargeOrderEntryPayload,
|
|
): AdminRechargeRefundActionResponse {
|
|
return {
|
|
outRefundNo: 'refund-1001',
|
|
providerStatus: 'SUCCESS',
|
|
resultCode: 'settled',
|
|
providerStatusUnknown: false,
|
|
order,
|
|
};
|
|
}
|
|
|
|
function orderWithActiveHold(): AdminRechargeOrderEntryPayload {
|
|
return {
|
|
...baseOrder,
|
|
activeHold: {
|
|
outRefundNo: 'refund-1001',
|
|
refundCents: 300,
|
|
heldPoints: 30,
|
|
status: 'pending',
|
|
adminUserId: 'admin-1',
|
|
reason: '用户申请',
|
|
createdAtMicros: 1_720_000_000_000_000,
|
|
updatedAtMicros: 1_720_000_000_000_000,
|
|
},
|
|
refundEligible: false,
|
|
refundBlockReasonCode: 'refund_in_progress',
|
|
};
|
|
}
|
|
|
|
function manualReviewRefund(
|
|
overrides: Partial<AdminRechargeRefundPayload> = {},
|
|
): AdminRechargeRefundPayload {
|
|
return {
|
|
outRefundNo: 'refund-manual-1',
|
|
providerRefundId: 'provider-refund-1',
|
|
providerTransactionId: 'wx-transaction-other',
|
|
providerStatus: 'SUCCESS',
|
|
totalCents: 600,
|
|
refundCents: 300,
|
|
payerRefundCents: 300,
|
|
successAtMicros: 1_720_000_000_000_000,
|
|
firstObservedAtMicros: 1_720_000_000_000_000,
|
|
updatedAtMicros: 1_720_000_000_000_000,
|
|
observationSource: 'wechat_query',
|
|
targetRecoveryPoints: 30,
|
|
recoveredPoints: 0,
|
|
unrecoveredPoints: 0,
|
|
recoveryStatus: 'manual_review',
|
|
lastErrorCode: 'provider_transaction_id_mismatch',
|
|
manualReviewResolvedByAdminUserId: null,
|
|
manualReviewResolutionReason: null,
|
|
manualReviewResolvedAtMicros: null,
|
|
manualReviewResolvedErrorCode: null,
|
|
...overrides,
|
|
};
|
|
}
|