Files
makelore/tests/unit/point-wallet.test.tsx
T

134 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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('honors unavailable recharge without asking the user to change account identity', 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.queryByText(/仅家长身份可以充值/)).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /500 点\s*¥10.00/ })).toBeDisabled();
});
it('recharges the displayed child account while leaving shared funding selected', async () => {
api.summary.mockResolvedValue(billingSummary({ points: permanentBalance({ total: null, used: null, reserved: null, total_remaining: null, family_shared: true, entitlement_source: 'shared_group', shared_available: true, can_recharge: false }) }));
render(<PointWallet accountName="小麦" accountUsername="child-account" />);
await screen.findByText('75 点');
fireEvent.click(screen.getByTestId('sidebar-point-wallet-open'));
const dialog = screen.getByTestId('point-wallet-dialog');
expect(screen.getByTestId('point-recharge-recipient')).toHaveTextContent('充值到:小麦(child-account)');
expect(dialog).toHaveTextContent('请由家长登录孩子的账号完成付款');
expect(dialog).toHaveTextContent('充值不会自动切换付款来源');
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).toHaveBeenCalledWith('recharge-10', expect.any(String));
expect(api.external).not.toHaveBeenCalled();
});
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();
});
});