1b05a1d05e
Project CI / AI game creator shell Rust smoke (push) Successful in 2m17s
Project CI / Backend tests (push) Successful in 4m59s
Project CI / AI game creator shell Rust lane 2/2 (push) Successful in 7m29s
Project CI / AI game creator shell Rust lane 1/2 (push) Successful in 8m40s
Project CI / Frontend tests (push) Successful in 1m59s
Project CI / AI game creator shell Rust crates (push) Successful in 9m16s
Project CI / AI game creator shell web tests (push) Successful in 1m41s
Project CI / Repository checks (push) Successful in 4m4s
Project CI / Native shell tests (push) Successful in 7m1s
- 新增 AdminRechargeOrderEntryPayload.paidAmountCents:只有 paid_at 存在的订单才有实付,未支付 / 已关闭 / 已过期固定为 0 - 充值管理列表把「金额 / 泥点」拆成「实付」「发放泥点」两列,未支付行实付显示「未支付」并附订单金额小字;退款面板「订单实付」改读同一字段 - 用户详情充值订单表新增「发放泥点」列,商品列只保留商品名,实付同样按 paidAmountCents 展示 - 用户详情新增 cumulativeRechargedCents:api-server 按 user_id 读取 profile_recharge_order,只累加 paid_at 存在的订单金额(退款不回减),单次上限 500 行,读取失败或命中上限返回 null - 用户详情身份区新增「累计充值」,读不到时显示「未知」,不用用户详情最多 20 条订单在 BFF 或前端近似重算 - 兑换码奖励单位收口为泥点:输入标签与列表列头改「奖励泥点」,单元格带「泥点」单位,避免被当成元 - 新增后端 2 条累计充值口径单测与前端 2 条用例(未支付不显示实付且发放泥点独立成列、累计充值未知态) - 同步后端架构数据契约(实付口径 / 累计充值来源与上限 / 兑换码奖励单位)与决策记录 - 验证:cargo check -p api-server;cargo test -p api-server --bin api-server admin(138 passed / 0 failed / 1 ignored);npm run admin-web:typecheck;npx vitest run apps/admin-web/src(220 passed);cargo fmt --all --check、check:encoding、check:doc-index、git diff --check 通过
319 lines
9.9 KiB
TypeScript
319 lines
9.9 KiB
TypeScript
/* @vitest-environment jsdom */
|
|
|
|
import { render, screen, waitFor } from '@testing-library/react';
|
|
import userEvent from '@testing-library/user-event';
|
|
import { beforeEach, expect, test, vi } from 'vitest';
|
|
|
|
import {
|
|
getAdminUserDetail,
|
|
reconcileAdminUserConsumption,
|
|
updateAdminWalletRestriction,
|
|
} from '../api/adminApiClient';
|
|
import type {
|
|
AdminProfileWalletPayload,
|
|
AdminUserDetailResponse,
|
|
} from '../api/adminApiTypes';
|
|
import { AdminUserReferenceButton } from './AdminUserReferenceButton';
|
|
|
|
vi.mock('../api/adminApiClient', () => ({
|
|
formatAdminApiError: vi.fn((error: unknown) =>
|
|
error instanceof Error ? error.message : '请求失败',
|
|
),
|
|
getAdminUserDetail: vi.fn(),
|
|
isAdminApiError: vi.fn(() => false),
|
|
reconcileAdminUserConsumption: vi.fn(),
|
|
updateAdminWalletRestriction: vi.fn(),
|
|
}));
|
|
|
|
const wallet: AdminProfileWalletPayload = {
|
|
userId: 'user-1',
|
|
totalBalance: 96,
|
|
spendableBalance: 40,
|
|
dailyFreePoints: 6,
|
|
membershipLimitedPoints: 20,
|
|
permanentPoints: 70,
|
|
heldPoints: 5,
|
|
refundDebtPoints: 25,
|
|
manualFrozen: false,
|
|
refundDebtFrozen: true,
|
|
walletFrozen: true,
|
|
manualRestriction: null,
|
|
};
|
|
|
|
const detail: AdminUserDetailResponse = {
|
|
userId: 'user-1',
|
|
publicUserCode: 'TN1001',
|
|
displayName: '陶泥用户',
|
|
avatarUrl: 'https://example.com/avatar.png',
|
|
phoneNumberMasked: '138****5678',
|
|
loginMethod: 'phone',
|
|
bindingStatus: 'bound',
|
|
phoneBound: true,
|
|
wechatBound: true,
|
|
historicalConsumedPoints: 1234,
|
|
cumulativeRechargedCents: 128800,
|
|
canReconcileConsumption: true,
|
|
wallet,
|
|
rechargeOrders: [
|
|
{
|
|
orderId: 'order-1',
|
|
userId: 'user-1',
|
|
user: null,
|
|
productId: 'points_60',
|
|
productTitle: '60泥点',
|
|
productKind: 'points',
|
|
amountCents: 600,
|
|
paidAmountCents: 600,
|
|
status: 'paid',
|
|
paymentChannel: 'wechat_native',
|
|
paidAtMicros: 1_720_000_000_000_000,
|
|
providerTransactionId: 'wx-1',
|
|
createdAtMicros: 1_720_000_000_000_000,
|
|
pointsDelta: 60,
|
|
cumulativeSuccessRefundCents: 300,
|
|
targetRecoveryPoints: 30,
|
|
recoveredPoints: 5,
|
|
unrecoveredPoints: 25,
|
|
recoveryStatus: 'shortfall',
|
|
wallet,
|
|
refunds: [],
|
|
activeHold: null,
|
|
remainingRefundableCents: 300,
|
|
refundEligible: false,
|
|
refundBlockReasonCode: 'refund_reconciliation_pending',
|
|
},
|
|
],
|
|
};
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(getAdminUserDetail).mockResolvedValue(detail);
|
|
vi.mocked(reconcileAdminUserConsumption).mockResolvedValue({
|
|
userId: 'user-1',
|
|
previousHistoricalConsumedPoints: 1234,
|
|
historicalConsumedPoints: 1300,
|
|
changed: true,
|
|
reconciledAtMicros: 1_720_000_000_000_000,
|
|
});
|
|
vi.mocked(updateAdminWalletRestriction).mockResolvedValue({ wallet });
|
|
});
|
|
|
|
test('用户查看按钮按内部 ID 查询并展示脱敏资料、余额与退款限制', async () => {
|
|
const user = userEvent.setup();
|
|
const parentClick = vi.fn();
|
|
render(
|
|
<div onClick={parentClick}>
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>
|
|
</div>,
|
|
);
|
|
|
|
const trigger = screen.getByRole('button', { name: '查看用户信息' });
|
|
await user.click(trigger);
|
|
expect(parentClick).not.toHaveBeenCalled();
|
|
expect(await screen.findByRole('dialog', { name: '用户详情' })).toBeTruthy();
|
|
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
|
|
userId: 'user-1',
|
|
publicUserCode: undefined,
|
|
});
|
|
expect(screen.getByText('陶泥用户')).toBeTruthy();
|
|
expect(screen.getAllByText('TN1001').length).toBeGreaterThanOrEqual(1);
|
|
expect(screen.getByText('138****5678')).toBeTruthy();
|
|
expect(screen.getByText('退款欠账限制')).toBeTruthy();
|
|
expect(screen.getByText('25', { selector: 'strong' })).toBeTruthy();
|
|
expect(screen.getByText('历史花费')).toBeTruthy();
|
|
expect(screen.getByText('1234', { selector: 'strong' })).toBeTruthy();
|
|
expect(screen.getByText('累计充值')).toBeTruthy();
|
|
expect(screen.getByText('¥1288.00')).toBeTruthy();
|
|
expect(screen.getByText('order-1')).toBeTruthy();
|
|
expect(screen.getByRole('columnheader', { name: '实付' })).toBeTruthy();
|
|
expect(screen.getByRole('columnheader', { name: '发放泥点' })).toBeTruthy();
|
|
|
|
await user.keyboard('{Escape}');
|
|
await waitFor(() =>
|
|
expect(screen.queryByRole('dialog', { name: '用户详情' })).toBeNull(),
|
|
);
|
|
await waitFor(() => expect(document.activeElement).toBe(trigger));
|
|
});
|
|
|
|
test('累计充值读取不到时展示未知,不用订单列表近似', async () => {
|
|
vi.mocked(getAdminUserDetail).mockResolvedValue({
|
|
...detail,
|
|
cumulativeRechargedCents: null,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
await screen.findByText('陶泥用户');
|
|
|
|
expect(screen.getByText('累计充值')).toBeTruthy();
|
|
expect(screen.getByText('累计充值').nextElementSibling?.textContent).toBe(
|
|
'未知',
|
|
);
|
|
});
|
|
|
|
test('只有陶泥号时按 publicUserCode 查询用户', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
publicUserCode="TN1001"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
await screen.findByText('陶泥用户');
|
|
expect(getAdminUserDetail).toHaveBeenCalledWith('admin-token', {
|
|
userId: undefined,
|
|
publicUserCode: 'TN1001',
|
|
});
|
|
});
|
|
|
|
test('历史花费支持手动对账并用权威结果校准展示', async () => {
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
await screen.findByText('陶泥用户');
|
|
await user.click(screen.getByRole('button', { name: '手动对账历史花费' }));
|
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
|
|
|
await waitFor(() => {
|
|
expect(reconcileAdminUserConsumption).toHaveBeenCalledWith('admin-token', {
|
|
userId: 'user-1',
|
|
});
|
|
});
|
|
expect(await screen.findByText('对账完成,历史花费已校准')).toBeTruthy();
|
|
expect(screen.getByText('1300', { selector: 'strong' })).toBeTruthy();
|
|
});
|
|
|
|
test('没有独立操作权限时不显示历史花费对账按钮', async () => {
|
|
vi.mocked(getAdminUserDetail).mockResolvedValue({
|
|
...detail,
|
|
canReconcileConsumption: false,
|
|
});
|
|
const user = userEvent.setup();
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
await screen.findByText('陶泥用户');
|
|
|
|
expect(screen.queryByRole('button', { name: '手动对账历史花费' })).toBeNull();
|
|
});
|
|
|
|
test('人工冻结和解除人工冻结分别提交原因且不解除退款欠账限制', async () => {
|
|
const user = userEvent.setup();
|
|
const manuallyFrozenWallet: AdminProfileWalletPayload = {
|
|
...wallet,
|
|
manualFrozen: true,
|
|
walletFrozen: true,
|
|
manualRestriction: {
|
|
frozen: true,
|
|
reason: '风险核查',
|
|
createdByAdminUserId: 'admin:root',
|
|
createdByAdminDisplayName: '后台负责人',
|
|
createdAtMicros: 1_720_000_000_000_000,
|
|
updatedByAdminUserId: 'admin:root',
|
|
updatedByAdminDisplayName: '后台负责人',
|
|
updatedAtMicros: 1_720_000_000_000_000,
|
|
},
|
|
};
|
|
vi.mocked(updateAdminWalletRestriction)
|
|
.mockResolvedValueOnce({ wallet: manuallyFrozenWallet })
|
|
.mockResolvedValueOnce({ wallet: { ...wallet, manualFrozen: false } });
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
await screen.findByText('陶泥用户');
|
|
|
|
await user.type(
|
|
screen.getByRole('textbox', { name: '人工冻结操作原因' }),
|
|
'异常登录',
|
|
);
|
|
await user.click(screen.getByRole('button', { name: '人工冻结钱包' }));
|
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
|
await waitFor(() => {
|
|
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(
|
|
1,
|
|
'admin-token',
|
|
{
|
|
userId: 'user-1',
|
|
frozen: true,
|
|
reason: '异常登录',
|
|
},
|
|
);
|
|
});
|
|
expect(await screen.findByText(/后台负责人/)).toBeTruthy();
|
|
expect(screen.queryByText(/admin:root/)).toBeNull();
|
|
|
|
expect(
|
|
await screen.findByText('解除人工冻结后,退款欠账限制仍会保留。'),
|
|
).toBeTruthy();
|
|
await user.type(
|
|
screen.getByRole('textbox', { name: '人工冻结操作原因' }),
|
|
'核查完成',
|
|
);
|
|
await user.click(screen.getByRole('button', { name: '解除人工冻结' }));
|
|
await user.click(screen.getByRole('button', { name: '确认' }));
|
|
await waitFor(() => {
|
|
expect(updateAdminWalletRestriction).toHaveBeenNthCalledWith(
|
|
2,
|
|
'admin-token',
|
|
{
|
|
userId: 'user-1',
|
|
frozen: false,
|
|
reason: '核查完成',
|
|
},
|
|
);
|
|
});
|
|
expect(screen.getByText('退款欠账限制')).toBeTruthy();
|
|
});
|
|
|
|
test('用户详情读取失败后可以重试', async () => {
|
|
const user = userEvent.setup();
|
|
vi.mocked(getAdminUserDetail)
|
|
.mockRejectedValueOnce(new Error('读取失败'))
|
|
.mockResolvedValueOnce(detail);
|
|
render(
|
|
<AdminUserReferenceButton
|
|
token="admin-token"
|
|
userId="user-1"
|
|
onUnauthorized={vi.fn()}
|
|
/>,
|
|
);
|
|
|
|
await user.click(screen.getByRole('button', { name: '查看用户信息' }));
|
|
expect(await screen.findByText('读取失败')).toBeTruthy();
|
|
await user.click(screen.getByRole('button', { name: '重试' }));
|
|
expect(await screen.findByText('陶泥用户')).toBeTruthy();
|
|
expect(getAdminUserDetail).toHaveBeenCalledTimes(2);
|
|
});
|