import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { beforeEach, expect, it, vi } from 'vitest'; import PointsPage from '@/pages/Points'; import { apiRequest } from '@/services/api'; vi.mock('@/services/api', () => ({ apiRequest: vi.fn(), downloadFile: vi.fn(), jsonOptions: (method: string, body: unknown) => ({ method, body: JSON.stringify(body) }), })); const rules = { enabled: true, timezone: 'Asia/Shanghai', checkin_enabled: true, checkin_button_enabled: true, checkin_points: 10, activity_enabled: true, activity_points: 1, activity_cooldown_seconds: 300, activity_daily_cap: 10, upvote_enabled: true, upvote_points: 2, upvote_pair_cooldown_seconds: 86400, upvote_daily_cap: 10, }; beforeEach(() => { vi.clearAllMocks(); vi.mocked(apiRequest).mockImplementation(async (path) => { if (path.startsWith('/chats?')) { return { items: [ { chat_id: '-100999', title: '不可用积分群', type: 'supergroup', accessible: false }, { chat_id: '-100123', title: '积分测试群', type: 'supergroup', accessible: true }, ], total: 2, page: 1, page_size: 100, }; } if (path.startsWith('/points/accounts?')) { return { items: [{ chat_id: '-100123', user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', balance: 180, lifetime_earned: 220, lifetime_spent: 40 }], total: 1, page: 1, page_size: 20, }; } if (path.startsWith('/points/transactions?')) { return { items: [{ transaction_id: 'tx-1', chat_id: '-100123', user_id: '10001', username: 'alice', display_name: '爱丽丝 示例', delta: 10, balance_after: 180, source: 'checkin', reason: 'Daily check-in', created_at: '2026-07-24T08:00:00Z' }], total: 1, page: 1, page_size: 20, }; } if (path.endsWith('/points/settings')) return rules; if (path === '/points/adjustments') return { created: true }; return {}; }); }); it('展示余额排行、流水并保存积分规则', async () => { render(); expect(await screen.findByText('爱丽丝 示例')).toBeInTheDocument(); expect(screen.queryByText('@alice')).not.toBeInTheDocument(); expect( vi.mocked(apiRequest).mock.calls.some(([path]) => path.includes('chat_id=-100999')), ).toBe(false); fireEvent.click(screen.getByRole('tab', { name: /流水/ })); expect(await screen.findByText('Daily check-in')).toBeInTheDocument(); fireEvent.click(screen.getByRole('tab', { name: '自动积分规则' })); expect(await screen.findByRole('heading', { name: '签到' })).toBeInTheDocument(); expect(screen.getByRole('switch', { name: '底部签到按钮' })).toBeChecked(); await waitFor(() => expect(apiRequest).toHaveBeenCalledWith('/chats/-100123/points/settings'), ); const saveButton = screen.getByRole('button', { name: /保\s*存\s*规\s*则/ }); await waitFor(() => expect(saveButton).toBeEnabled()); fireEvent.click(saveButton); expect( vi.mocked(apiRequest).mock.calls.some( ([path, options]) => path === '/chats/-100123/points/settings' && options?.method === 'PUT', ), ).toBe(false); fireEvent.click(await screen.findByRole('button', { name: /确\s*认\s*保\s*存/ })); await waitFor(() => { const call = vi.mocked(apiRequest).mock.calls.find( ([path, options]) => path === '/chats/-100123/points/settings' && options?.method === 'PUT', ); expect(call).toBeTruthy(); expect(JSON.parse(String(call?.[1]?.body))).toMatchObject({ checkin_button_enabled: true, confirm: true, }); }); }); it('手工调整必须二次确认并携带确认标记', async () => { render(); await screen.findByText('爱丽丝 示例'); fireEvent.click(screen.getByRole('button', { name: /手\s*工\s*调\s*整/ })); fireEvent.change(screen.getByLabelText('用户 ID'), { target: { value: '10001' } }); fireEvent.change(screen.getByLabelText('积分数值'), { target: { value: '25' } }); fireEvent.change(screen.getByLabelText('原因'), { target: { value: '活动补发' } }); fireEvent.click(screen.getByRole('button', { name: /继\s*续\s*确\s*认/ })); expect(vi.mocked(apiRequest).mock.calls.some(([path]) => path === '/points/adjustments')).toBe(false); fireEvent.click(await screen.findByRole('button', { name: /确\s*认\s*写\s*入/ })); await waitFor(() => { const call = vi.mocked(apiRequest).mock.calls.find(([path]) => path === '/points/adjustments'); expect(call).toBeTruthy(); expect(JSON.parse(String(call?.[1]?.body))).toMatchObject({ chat_id: '-100123', user_id: '10001', amount: 25, reason: '活动补发', confirm: true, }); }); });