import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, create, type ReactTestInstance } from 'react-test-renderer'; import { MemoryRouter } from 'react-router-dom'; import { ToastProvider } from '../components/Toast.js'; import DownstreamKeys from './DownstreamKeys.js'; const { apiMock } = vi.hoisted(() => ({ apiMock: { getDownstreamApiKeysSummary: vi.fn(), getDownstreamApiKeys: vi.fn(), getRoutesLite: vi.fn(), getDownstreamApiKeyOverview: vi.fn(), getDownstreamApiKeyTrend: vi.fn(), createDownstreamApiKey: vi.fn(), batchDownstreamApiKeys: vi.fn(), updateDownstreamApiKey: vi.fn(), deleteDownstreamApiKey: vi.fn(), resetDownstreamApiKeyUsage: vi.fn(), }, })); vi.mock('../api.js', () => ({ api: apiMock })); vi.mock('../components/useIsMobile.js', () => ({ useIsMobile: () => true, })); vi.mock('react-dom', async () => { const actual = await vi.importActual('react-dom'); return { ...actual, createPortal: (node: unknown) => node, }; }); vi.mock('../components/useAnimatedVisibility.js', () => ({ useAnimatedVisibility: (open: boolean) => ({ shouldRender: open, isVisible: open, }), })); vi.mock('../components/charts/DownstreamKeyTrendChart.js', () => ({ default: ({ buckets }: { buckets: Array<{ totalTokens: number }> }) => (
{`trend:${buckets.length}`}
), })); vi.mock('../components/ModernSelect.js', () => ({ default: ({ value, onChange, options }: { value: string; onChange: (value: string) => void; options: Array<{ value: string; label: string }> }) => ( ), })); function collectText(node: ReactTestInstance): string { return (node.children || []).map((child) => { if (typeof child === 'string') return child; return collectText(child); }).join(''); } function findButtonByText(root: ReactTestInstance, text: string): ReactTestInstance { return root.find((node) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node) === text )); } function findGhostButtonByText(root: ReactTestInstance, text: string): ReactTestInstance { return root.find((node) => ( node.type === 'button' && typeof node.props.onClick === 'function' && collectText(node) === text && String(node.props.className || '').includes('btn btn-ghost') )); } async function flushMicrotasks() { await act(async () => { await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); }); } function buildSummaryItem(id: number, overrides?: Partial) { return { id, name: `mobile-key-${id}`, keyMasked: `sk-k****${id}`, enabled: true, description: 'mobile smoke', groupName: '项目A', tags: ['移动端'], expiresAt: null, maxCost: null, usedCost: 0, maxRequests: null, usedRequests: 0, supportedModels: ['gpt-4.1-mini'], allowedRouteIds: [11], siteWeightMultipliers: {}, lastUsedAt: '2026-03-15T08:27:25.378Z', createdAt: '2026-03-15T08:27:25.378Z', updatedAt: '2026-03-15T08:27:25.378Z', rangeUsage: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42, }, ...overrides, }; } function buildRawItem(id: number, overrides?: Partial) { return { id, name: `mobile-key-${id}`, key: `sk-mobile-${id}`, keyMasked: `sk-k****${id}`, description: 'mobile smoke', groupName: '项目A', tags: ['移动端'], enabled: true, expiresAt: null, maxCost: null, usedCost: 0, maxRequests: null, usedRequests: 0, supportedModels: ['gpt-4.1-mini'], allowedRouteIds: [11], siteWeightMultipliers: {}, lastUsedAt: '2026-03-15T08:27:25.378Z', ...overrides, }; } describe('DownstreamKeys mobile layout', () => { beforeEach(() => { vi.clearAllMocks(); (globalThis as any).document = { body: { style: {} }, addEventListener: vi.fn(), removeEventListener: vi.fn(), }; apiMock.getDownstreamApiKeysSummary.mockResolvedValue({ success: true, items: [buildSummaryItem(1), buildSummaryItem(2)], }); apiMock.getDownstreamApiKeys.mockResolvedValue({ success: true, items: [buildRawItem(1), buildRawItem(2)], }); apiMock.getRoutesLite.mockResolvedValue([ { id: 11, modelPattern: 'gpt-4.1-mini', displayName: 'GPT 4.1 Mini', enabled: true }, ]); apiMock.getDownstreamApiKeyOverview.mockResolvedValue({ success: true, item: buildSummaryItem(1), usage: { last24h: { totalRequests: 3, successRequests: 2, failedRequests: 1, successRate: 66.7, totalTokens: 4200, totalCost: 0.42 }, last7d: { totalRequests: 9, successRequests: 8, failedRequests: 1, successRate: 88.9, totalTokens: 12400, totalCost: 1.24 }, all: { totalRequests: 20, successRequests: 18, failedRequests: 2, successRate: 90, totalTokens: 55200, totalCost: 5.52 }, }, }); apiMock.getDownstreamApiKeyTrend.mockResolvedValue({ success: true, buckets: [ { startUtc: '2026-03-15T08:00:00.000Z', totalRequests: 2, totalTokens: 1200, totalCost: 0.12, successRate: 100 }, ], }); apiMock.createDownstreamApiKey.mockResolvedValue({ success: true }); apiMock.batchDownstreamApiKeys.mockResolvedValue({ success: true, successIds: [1, 2], failedItems: [] }); apiMock.updateDownstreamApiKey.mockResolvedValue({ success: true }); apiMock.deleteDownstreamApiKey.mockResolvedValue({ success: true }); apiMock.resetDownstreamApiKeyUsage.mockResolvedValue({ success: true }); }); afterEach(() => { vi.clearAllMocks(); delete (globalThis as any).document; }); it('renders mobile cards and supports select-all-visible batch actions', async () => { let root!: WebTestRenderer; try { await act(async () => { root = create( , ); }); await flushMicrotasks(); const cards = root!.root.findAll((node) => node.props?.className === 'mobile-card'); expect(cards.length).toBe(2); expect(collectText(root!.root)).toContain('全选可见'); const selectAllButton = findButtonByText(root!.root, '全选可见'); await act(async () => { selectAllButton.props.onClick(); }); await flushMicrotasks(); expect(collectText(root!.root)).toContain('已选 2 个密钥'); expect(collectText(root!.root)).toContain('取消全选'); const disableButton = findGhostButtonByText(root!.root, '禁用'); await act(async () => { disableButton.props.onClick(); }); await flushMicrotasks(); expect(apiMock.batchDownstreamApiKeys).toHaveBeenCalledWith({ ids: [1, 2], action: 'disable', }); } finally { root?.unmount(); } }); });