117 lines
8.7 KiB
TypeScript
117 lines
8.7 KiB
TypeScript
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(<PointWallet />);
|
||
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(<PointWallet />); 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<typeof rechargeOrder>) => 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(<PointWallet />); 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(<PointWallet />); 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<typeof billingSummary>) => void;
|
||
api.summary.mockReturnValueOnce(new Promise((done) => { resolve = done; }));
|
||
const view = render(<PointWallet key="account-a" />);
|
||
api.summary.mockResolvedValue(billingSummary({ own_points: permanentBalance({ total_remaining: '2.00' }) }));
|
||
view.rerender(<PointWallet key="account-b" />); await screen.findByText('2 点');
|
||
await act(async () => resolve(billingSummary({ own_points: permanentBalance({ total_remaining: '999.00' }) })));
|
||
expect(screen.queryByText('999 点')).not.toBeInTheDocument();
|
||
});
|
||
});
|