/* @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(
, ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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( , ); 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); });