/** @vitest-environment jsdom */ import { act, renderHook } from '@testing-library/react'; import { createElement } from 'react'; import { renderToString } from 'react-dom/server'; import { beforeEach, describe, expect, test, vi } from 'vitest'; const clientApi = vi.hoisted(() => ({ confirmClientWechatProfileRechargeOrder: vi.fn(), createClientProfileRechargeOrder: vi.fn(), getClientProfileRechargeCenter: vi.fn(), getClientProfileWalletLedger: vi.fn(), })); vi.mock('../src/services/clientApi', () => clientApi); import { useAccountWallet } from '../src/features/app-shell/useAccountWallet'; import { useWalletStore } from '../src/stores/useWalletStore'; function detailedBalance(totalPoints: number) { return { totalPoints, permanentPoints: Math.max(0, totalPoints - 20), limitedPoints: 20, limitedExpiresAt: '2026-08-01T00:00:00Z', dailyFreePoints: 0, dailyFreeResetPoints: 20, dailyFreeResetsAt: '2026-07-18T00:00:00Z', }; } describe('useWalletStore', () => { beforeEach(() => { useWalletStore.getState().resetWalletBalance(); useWalletStore.getState().setWalletOwner('user-a'); vi.clearAllMocks(); }); test('keeps mudPointBalance as the authoritative detailed balance state', async () => { const mudPointBalance = detailedBalance(120); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 120, mudPointBalance, }); const { result } = renderHook(() => useWalletStore()); await act(async () => { await result.current.onWalletBalanceMayHaveChanged(); }); expect(result.current.mudPointBalance).toEqual(mudPointBalance); expect(result.current.legacyWalletBalance).toBe(120); expect(result.current.mudPointBalanceStatus).toBe('ready'); expect(result.current.mudPointBalanceError).toBe(''); expect(Object.keys(result.current).sort()).toEqual([ 'applyLegacyWalletBalanceSnapshot', 'applyWalletBalanceSnapshot', 'captureWalletBalanceSnapshot', 'legacyWalletBalance', 'mudPointBalance', 'mudPointBalanceError', 'mudPointBalanceStatus', 'onWalletBalanceMayHaveChanged', 'ownerUserId', 'resetWalletBalance', 'setWalletOwner', ]); }); test('recovers from a failed refresh with a recharge-center snapshot', async () => { clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce( new Error('首次读取失败'), ); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(120); const snapshot = useWalletStore .getState() .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); test('does not let an older refresh overwrite an applied snapshot', async () => { let rejectRequest: ((reason: Error) => void) | undefined; clientApi.getClientProfileRechargeCenter.mockImplementationOnce( () => new Promise((_, reject) => { rejectRequest = reject; }), ); const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); const mudPointBalance = detailedBalance(160); const snapshot = useWalletStore .getState() .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() .applyWalletBalanceSnapshot(snapshot!, mudPointBalance); rejectRequest?.(new Error('过期请求失败')); await refresh; expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('ready'); expect(useWalletStore.getState().mudPointBalanceError).toBe(''); }); test('rejects a response that omits mudPointBalance instead of inventing a breakdown', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 120, }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toBeNull(); expect(useWalletStore.getState().legacyWalletBalance).toBe(120); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( '泥点明细读取失败', ); }); test('uses mudPointBalance from recharge-center as the authoritative snapshot', async () => { const mudPointBalance = detailedBalance(180); clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 999, mudPointBalance, }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance).toEqual(mudPointBalance); }); test('discards an outdated response and performs one trailing refresh', async () => { let resolveFirst: ((value: unknown) => void) | undefined; clientApi.getClientProfileRechargeCenter .mockImplementationOnce( () => new Promise((resolve) => { resolveFirst = resolve; }), ) .mockResolvedValueOnce({ walletBalance: 80, mudPointBalance: detailedBalance(80), }); const firstRefresh = useWalletStore .getState() .onWalletBalanceMayHaveChanged(); const trailingRefresh = useWalletStore .getState() .onWalletBalanceMayHaveChanged(); resolveFirst?.({ walletBalance: 120, mudPointBalance: detailedBalance(120), }); await Promise.all([firstRefresh, trailingRefresh]); expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2); expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(80); }); test('retains the last balance when a refresh fails', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ walletBalance: 90, mudPointBalance: detailedBalance(90), }); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); clientApi.getClientProfileRechargeCenter.mockRejectedValueOnce( new Error('刷新失败'), ); await useWalletStore.getState().onWalletBalanceMayHaveChanged(); expect(useWalletStore.getState().mudPointBalance?.totalPoints).toBe(90); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('error'); expect(useWalletStore.getState().mudPointBalanceError).toBe( '泥点明细读取失败', ); }); test('does not restore a late response after reset', async () => { let resolveRequest: ((value: unknown) => void) | undefined; clientApi.getClientProfileRechargeCenter.mockImplementationOnce( () => new Promise((resolve) => { resolveRequest = resolve; }), ); const refresh = useWalletStore.getState().onWalletBalanceMayHaveChanged(); useWalletStore.getState().resetWalletBalance(); resolveRequest?.({ walletBalance: 75, mudPointBalance: detailedBalance(75), }); await refresh; expect(useWalletStore.getState().mudPointBalance).toBeNull(); expect(useWalletStore.getState().mudPointBalanceStatus).toBe('idle'); }); test('clears immediately when the adapter switches wallet owners', () => { const snapshot = useWalletStore .getState() .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); useWalletStore.getState().setWalletOwner('user-b'); expect(useWalletStore.getState()).toMatchObject({ ownerUserId: 'user-b', mudPointBalance: null, mudPointBalanceStatus: 'idle', }); }); test('masks another owner snapshot before the owner-binding effect runs', () => { const snapshot = useWalletStore .getState() .captureWalletBalanceSnapshot('user-a'); useWalletStore .getState() .applyWalletBalanceSnapshot(snapshot!, detailedBalance(88)); function WalletProbe() { const wallet = useAccountWallet('user-b'); return createElement( 'span', null, wallet.mudPointBalance?.totalPoints ?? 'empty', ); } expect(renderToString(createElement(WalletProbe))).toContain('empty'); expect(useWalletStore.getState().ownerUserId).toBe('user-a'); }); test('binds the account owner and refreshes again when the window regains focus', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 18, mudPointBalance: detailedBalance(18), }); renderHook(() => useAccountWallet('user-a')); await act(async () => undefined); expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(1); await act(async () => { window.dispatchEvent(new Event('focus')); }); expect(clientApi.getClientProfileRechargeCenter).toHaveBeenCalledTimes(2); expect(useWalletStore.getState()).toMatchObject({ ownerUserId: 'user-a', mudPointBalance: detailedBalance(18), }); }); test('keeps a successful legacy recharge total when the breakdown refresh fails', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValueOnce({ walletBalance: 90, mudPointBalance: detailedBalance(90), }); const { result } = renderHook(() => useAccountWallet('user-a')); await act(async () => undefined); clientApi.getClientProfileRechargeCenter .mockResolvedValueOnce({ walletBalance: 37, membership: null, pointProducts: [], membershipProducts: [], benefits: [], latestOrder: null, hasPointsRecharged: false, }) .mockRejectedValueOnce(new Error('明细刷新失败')); await act(async () => { await result.current.loadRechargeCenter(); await Promise.resolve(); }); expect(result.current.legacyWalletBalance).toBe(37); expect(result.current.mudPointBalance).toBeNull(); }); test('keeps ledger loading independent from the recharge lifecycle', async () => { let resolveLedger!: (value: { entries: [] }) => void; clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockImplementation( () => new Promise((resolve) => { resolveLedger = resolve; }), ); const { result } = renderHook(() => useAccountWallet('user-a')); await act(async () => undefined); act(() => { result.current.openWalletLedger(); result.current.openRecharge(); }); expect(result.current.walletLedgerLoading).toBe(true); await act(async () => { resolveLedger({ entries: [] }); }); expect(result.current.walletLedger).toEqual({ entries: [] }); expect(result.current.walletLedgerLoading).toBe(false); }); test('hides account-owned dialogs and data when the account changes', async () => { clientApi.getClientProfileRechargeCenter.mockResolvedValue({ walletBalance: 18, mudPointBalance: detailedBalance(18), }); clientApi.getClientProfileWalletLedger.mockResolvedValue({ entries: [] }); const { result, rerender } = renderHook( ({ userId }) => useAccountWallet(userId), { initialProps: { userId: 'user-a' } }, ); await act(async () => undefined); await act(async () => { result.current.openWalletLedger(); result.current.openRecharge(); }); expect(result.current.walletLedgerOpen).toBe(true); expect(result.current.rechargeOpen).toBe(true); await act(async () => { rerender({ userId: 'user-b' }); }); expect(result.current.walletLedgerOpen).toBe(false); expect(result.current.walletLedger).toBeNull(); expect(result.current.rechargeOpen).toBe(false); expect(result.current.nativeRechargePayment).toBeNull(); }); });