Files
Genarrative/apps/admin-web/src/components/AdminUserDetailDialog.test.tsx
T
kdletters 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 通过
2026-09-23 20:10:20 +08:00

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);
});