import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { PointWallet } from '@/components/account/PointWallet'; import { AppError } from '@/lib/error-model'; import { billingSummary, permanentBalance, rechargeOrder } from '../fixtures/permanent-points'; const api = vi.hoisted(() => ({ summary: vi.fn(), orders: vi.fn(), history: vi.fn(), create: vi.fn(), order: vi.fn(), external: vi.fn() })); vi.mock('@/lib/works-billing', () => ({ fetchWorksBillingSummary: api.summary, fetchWorksRechargeOrders: api.orders, fetchWorksPointHistory: api.history, createWorksRechargeOrder: api.create, fetchWorksRechargeOrder: api.order, openWorksBillingAccount: api.external })); vi.mock('@/lib/api-client', () => ({ invokeIpc: api.external })); vi.mock('qrcode', () => ({ default: { toDataURL: vi.fn(async () => 'data:image/png;base64,test') } })); beforeEach(() => { Object.values(api).forEach((mock) => mock.mockReset()); api.summary.mockResolvedValue(billingSummary()); api.orders.mockResolvedValue({ items: [] }); api.history.mockResolvedValue({ items: [], has_more: false }); api.create.mockResolvedValue(rechargeOrder()); api.external.mockResolvedValue(undefined); }); async function openWallet() { render(); await screen.findByText('75 点'); fireEvent.click(screen.getByTestId('sidebar-point-wallet-open')); const dialog = screen.getByTestId('point-wallet-dialog'); await waitFor(() => expect(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })).toBeEnabled()); return dialog; } describe('permanent point wallet', () => { it('shows personal permanent balance without membership, cycle or reset-card controls', async () => { const dialog = await openWallet(); expect(dialog).toHaveTextContent('永久有效'); expect(screen.queryByText(/重置卡|升级订阅|本周剩余/)).not.toBeInTheDocument(); }); it('shows youth own precision and shared availability without enabling recharge', async () => { api.summary.mockResolvedValue(billingSummary({ can_recharge: false, payment_configured: false, own_points: permanentBalance({ can_recharge: false }), points: permanentBalance({ total: null, used: null, reserved: null, total_remaining: null, family_shared: true, entitlement_source: 'family_owner', shared_available: false, can_recharge: false }) })); render(); await screen.findByText('75 点'); expect(screen.getByText(/AI 编程使用共享点数/)).toHaveTextContent('已用尽'); fireEvent.click(screen.getByTestId('sidebar-point-wallet-open')); await screen.findByText(/仅家长身份可以充值/); expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled(); }); it('does not mistake the family payer’s own shared wallet for an exhausted external wallet', async () => { api.summary.mockResolvedValue(billingSummary({ points: permanentBalance({ family_shared: true, entitlement_source: 'shared_group' }) })); await openWallet(); expect(screen.queryByText(/AI 编程使用共享点数|已用尽/)).not.toBeInTheDocument(); }); it('does not duplicate rapid checkout and displays the frozen original order after repricing', async () => { const dialog = await openWallet(); let resolve!: (value: ReturnType) => void; api.create.mockReturnValue(new Promise((done) => { resolve = done; })); const buy = within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ }); fireEvent.click(buy); fireEvent.click(buy); expect(api.create).toHaveBeenCalledTimes(1); const changed = billingSummary(); changed.recharge_products[0] = { ...changed.recharge_products[0], point_amount: '1000.00', amount_cents: 2000 }; api.summary.mockResolvedValue(changed); api.orders.mockResolvedValue({ items: [rechargeOrder()] }); await act(async () => resolve(rechargeOrder())); await screen.findByAltText('充值支付二维码'); expect(screen.getByTestId('point-recharge-checkout')).toHaveTextContent('¥10.00 · 500 点'); expect(within(dialog).queryByRole('button', { name: /1,000 点\s*¥20.00/ })).not.toBeInTheDocument(); }); it('reuses an unknown request identity and never automatically retries payment', async () => { const dialog = await openWallet(); api.create.mockRejectedValueOnce(new Error('lost response')); fireEvent.click(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })); const retry = await screen.findByRole('button', { name: '按原请求重试' }); await waitFor(() => expect(retry).toBeEnabled()); expect(api.create).toHaveBeenCalledTimes(1); fireEvent.click(retry); await screen.findByTestId('point-recharge-checkout'); expect(api.create.mock.calls[1]).toEqual(api.create.mock.calls[0]); }); it('permits a fresh explicit intent only after a definitive rejection', async () => { const dialog = await openWallet(); api.create.mockRejectedValueOnce(new AppError('NETWORK', 'rejected', undefined, { commandOutcome: 'definitive_failure' })); fireEvent.click(within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ })); await screen.findByRole('alert'); const buy = within(dialog).getByRole('button', { name: /500 点\s*¥10.00/ }); await waitFor(() => expect(buy).toBeEnabled()); fireEvent.click(buy); await screen.findByTestId('point-recharge-checkout'); expect(api.create.mock.calls[1][1]).not.toBe(api.create.mock.calls[0][1]); }); it('loads an existing pending order without initiating another payment and accepts only server confirmation', async () => { api.orders.mockResolvedValue({ items: [rechargeOrder()] }); render(); await screen.findByText('75 点'); fireEvent.click(screen.getByTestId('sidebar-point-wallet-open')); fireEvent.click(await screen.findByRole('button', { name: '查看原订单' })); await screen.findByAltText('充值支付二维码'); api.order.mockResolvedValue(rechargeOrder({ status: 'succeeded', qr_payload: null, paid_at: '2026-09-22T01:00:00Z' })); api.summary.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '575.00' }) })); api.orders.mockResolvedValue({ items: [rechargeOrder({ status: 'succeeded', qr_payload: null })] }); fireEvent.click(screen.getByRole('button', { name: '刷新此订单' })); await screen.findByText('575 点'); expect(screen.queryByAltText('充值支付二维码')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); }); it('pages signed usage history and preserves pending-review holds', async () => { const dialog = await openWallet(); api.history.mockResolvedValue({ items: [{ id: 'usage', kind: 'usage', description: 'AI 编程', status: 'pending_review', points: '0.00', reserved_points: '1.50', created_at: '2026-09-22T00:00:00Z' }], has_more: true }); fireEvent.click(within(dialog).getByRole('tab', { name: '收支记录' })); await screen.findByText(/预占 1.5 点/); fireEvent.click(screen.getByRole('button', { name: '下一页' })); await waitFor(() => expect(api.history).toHaveBeenCalledWith(20)); }); it('blocks recharge on unreadable orders and shows manual review without a payment QR', async () => { api.orders.mockRejectedValueOnce(new Error('offline')); render(); await screen.findByText('75 点'); fireEvent.click(screen.getByTestId('sidebar-point-wallet-open')); await screen.findByText('记录加载失败,请刷新后再充值。'); expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled(); api.orders.mockResolvedValue({ items: [rechargeOrder({ status: 'manual_review', manual_review_required: true, qr_payload: null })] }); fireEvent.click(screen.getByRole('button', { name: '刷新', exact: true })); fireEvent.click(await screen.findByRole('button', { name: '查看原订单' })); await screen.findByText('付款结果需要运营核查,请勿再次付款。'); expect(screen.queryByAltText('充值支付二维码')).not.toBeInTheDocument(); expect(api.create).not.toHaveBeenCalled(); }); it('discarded account components cannot receive an old balance or checkout response', async () => { let resolve!: (value: ReturnType) => void; api.summary.mockReturnValueOnce(new Promise((done) => { resolve = done; })); const view = render(); api.summary.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) })); view.rerender(); await screen.findByText('2 点'); await act(async () => resolve(billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) }))); expect(screen.queryByText('999 点')).not.toBeInTheDocument(); }); });