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

117 lines
8.7 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('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();
});
});